媒体活动推荐系统这个题目,听起来像是“活动报名网站加了个热门列表”,但你真正动手做的时候会发现,最难的部分根本不是增删改查,而是“推荐”这两个字怎么落地。这篇聊聊我基于SpringBoot + Vue做的一个媒体活动推荐系统,从场景拆解、推荐策略、权限设计到部署上线的完整过程,重点讲清楚每一步为什么这么做,踩过哪些坑,希望能给正在做同类型项目的同学一个相对完整的参考。1. 这个系统到底在解决什么问题1.1 “推荐”不等于“随机洗脸”很多新手拿到这个标题,第一反应是:活动表里按时间排序,再根据点击量排个热门榜,这不就是推荐吗?我的建议是先把这句话忘掉。真正的媒体活动推荐系统,面对的是信息过载的问题——活动数量一旦超过两三百条,用户基本上只会看前三屏,后面再丰富的内容都沉底了。这时候,把最匹配用户兴趣的活动顶到前面,才算推荐。我做这个项目的起因,是某高校的社团联合会想统一管理校内外的文体活动。他们原先靠公众号推文和群消息转发,活动分散在多个渠道,用户根本不知道有哪些活动。系统的核心需求就三条:第一,活动信息集中展示;第二,每个用户打开首页看到的推荐内容不一样;第三,管理员能发布活动、审核内容、查看报名数据。这三条里,第一条和第三条是标准CRUD,第二条才是项目真正的技术含量所在。1.2 场景还原:谁会用什么设备来看什么内容在动手写代码之前,我花了很长时间梳理用户场景。这个系统的用户大致分四类:在校学生:关心周末有什么演出、展览、比赛,愿意报名的活动越少越好,希望首页直接推对口内容。社团干事:需要发布活动、管理报名名单,更关注后台操作的效率,而不是推荐效果。活动赞助方:偶尔登录查看某个活动的浏览量和报名转化,对权限隔离有要求。系统管理员:负责账号管理、内容审核、数据统计,需要全局视角。这四类用户的诉求不一样,意味着同一个页面不能只有一种展示逻辑。推荐位只对普通用户生效,管理后台则是功能菜单,压根不用推荐算法。这个区分看似简单,但很多类似项目第一版就把首页做成一刀切,结果后台管理员也看到一堆“猜你喜欢”,体验非常怪异。1.3 我最终圈定的三个核心模块基于场景还原,我把系统拆成三个相对独立的模块:一是活动信息模块,包含活动的创建、编辑、上下架、分类标签、封面图、时间地点、报名名额等基础信息;二是推荐模块,根据用户的历史行为和兴趣标签,计算活动推荐分数;三是管理模块,包含用户管理、活动审核、报名管理、数据看板。这三个模块在数据库层面可以共用活动表和用户表,但业务逻辑上必须解耦。我的做法是,推荐模块单独写一个服务类,不直接操作活动表的Mapper,而是通过一个推荐服务接口去读取活动快照。这样后续想换更复杂的推荐算法,不需要动活动管理的代码。2. 为什么选SpringBoot + Vue,而不是别的组合2.1 后端选型:Java生态下的快速交付与稳定背书SpringBoot在这个项目里的角色,不只是提供Web接口,它把整个后端的技术栈统一成了一个东西。我的选型逻辑主要有三点:第一,SpringBoot的自动配置能极大减少搭建成本。项目里用到的MySQL、Redis、JWT鉴权、定时任务,都是通过starter一键引入,不需要像传统SSM那样手工写一堆XML配置。对于这种以业务逻辑为主的系统,开发效率是第一位的。第二,Spring的生态够稳。推荐模块虽然我第一版用的是标签打分,但后续完全有可能接更复杂的数据。在Spring生态下,换算法只是替换Service实现类的问题,接口层不用动。第三,团队协作成本低。Java的强类型约束让前后端联调时不容易扯皮。接口文档一出来,字段名对不上这种低级错误基本不会发生,因为编译期就能发现。2.2 前端选型:Vue组件化带来的页面复用Vue这边,我看中的是组件复用能力和渐进式接入的特性。这个系统的页面虽然不算多,但活动卡片、标签筛选、轮播推荐位、报名弹窗这些组件会在多个页面重复出现。比如首页有推荐活动卡片,活动列表页也有同样的卡片,只不过数据来源不同。如果用传统模板引擎,遇上这种需求要么复制粘贴,要么用include,后期改一个卡片样式要改好几处。Vue里我把ActivityCard做成了全局组件,传入一个activity对象,内部自己处理封面、标签、状态、报名按钮的联动,任何页面引入就能用。前端除了Vue2/Vue3本身,我还加了Vue Router和Pinia。Vue Router处理页面跳转和路由守卫,Pinia存用户登录状态和兴趣标签。没有上Element UI全家桶,因为活动卡片这类自定义UI比较强,UI框架的表格和表单组件够用就行,过度依赖框架反而让页面长得千篇一律。2.3 前后端分离的分层逻辑前后端分离是我刻意坚持的,因为这个项目未来必然要扩展成多端。接口层按RESTful风格设计,前端只和接口打交道。后端的包结构大概是这样:com.example.mediaevent ├── controller // Web层,接收请求 ├── service // 业务层,包含推荐服务 │ └── recommend // 推荐模块独立子包 ├── mapper // 数据访问层 ├── entity // 实体类 ├── config // 配置类,包括安全配置 └── common // 统一返回、异常处理等这个分包的核心思想是,推荐模块的代码不要散落到活动的CRUD逻辑里。我见过不少人把推荐算法直接写在Controller里,一百多行业务代码挤一个方法,后续想改算法得把整个Controller翻一遍。独立分包之后,推荐服务只需要对上层暴露一个recommendActivities(Long userId, int limit)方法,内部逻辑随时可以优化。3. 推荐模块落地:我不卷模型,只做三件事3.1 用户兴趣画像的建立方式推荐系统的第一步,是得知道用户喜欢什么。在这个系统里,我采用“显式画像加隐式行为”的组合方案。显式画像来自用户注册时选择的兴趣标签。我预置了音乐、舞蹈、戏剧、体育、展览、讲座、影视、公益这八个媒体活动类别,用户至少选三个。这里有一个设计细节:注册时标签不要超过八个,因为选项太多会加重用户决策负担,实际勾选率会明显下降。隐式行为来自用户的操作记录。我定义了一套行为权重体系:行为类型行为说明默认权重BROWSE浏览活动详情1.0COLLECT收藏活动3.0SIGN_UP报名活动5.0SHARE转发分享4.0CANCEL取消报名-2.0用户每产生一个行为,后台会异步写入行为日志表,定时任务每小时汇总一次,更新用户的标签偏好分数。这个设计的好处是,用户第一次登录时靠显式画像推荐,随着行为积累,隐式画像的权重逐渐超过初始标签,推荐结果就越来越精准了。3.2 活动标签打分与相似度计算有了画像之后,怎么把用户和活动匹配起来?我用的不是复杂的协同过滤,而是一个简化版的标签空间余弦相似度。每个用户维护一个兴趣向量,每个活动维护一个标签向量。向量维度就是这些媒体类别。比如一个用户平时喜欢看戏剧和音乐,那向量可能是:{戏剧: 0.75, 音乐: 0.6, 影视: 0.3, ...}。活动发布时也会打标签向量,比如一部话剧演出,标签向量就是{戏剧: 0.9, 其他: 0.1}。计算匹配分数时,用余弦相似度公式:similarity = (v1·v2) / (|v1| × |v2|)其中v1是用户向量,v2是活动向量。向量点积越大,说明两个向量在方向上越接近,也就是用户越可能喜欢这个活动。代码实现大概是这样:public double calculateSimilarity(Map<String, Double> userVector, Map<String, Double> activityVector) { double dotProduct = 0.0; double userNorm = 0.0; double activityNorm = 0.0; for (String tag : userVector.keySet()) { Double userWeight = userVector.getOrDefault(tag, 0.0); Double activityWeight = activityVector.getOrDefault(tag, 0.0); dotProduct += userWeight * activityWeight; userNorm += userWeight * userWeight; } for (Double weight : activityVector.values()) { activityNorm += weight * weight; } if (userNorm == 0.0 || activityNorm == 0.0) { return 0.0; } return dotProduct / (Math.sqrt(userNorm) * Math.sqrt(activityNorm)); }计算完之后,用相似度乘以一个时间衰减因子,再加上基础权重,得到最终排序分。时间衰减因子我用的是exp(-ageDays / 30),意思是活动发布越久,推荐优先级越低,避免老活动一直霸榜。3.3 冷启动和新活动上线的兜底策略冷启动是所有推荐系统绕不开的问题,主要分两种情况。第一种是用户冷启动,也就是新用户没有行为数据。我的方案是注册时选的兴趣标签直接作为初始向量,如果连标签都没选,就返回当前时间段的默认推荐——比如周末优先推演出类,工作日优先推讲座展览类。另一个思路是参考地理位置,优先推荐离用户近的活动,但第一版我没做LBS,只按时间段和活动热度兜底。第二种是活动冷启动,也就是新发布的活动还没人报名,没有点击量,很容易被推荐算法忽略。我加了一个保底机制:新活动在上架后24小时内会获得一个基础加分,不受热度因子影响。这个时间窗口很关键,长于24小时容易导致三天的垃圾活动一直冒头,短了又给不到足够曝光。实际操作中,这套推荐策略的表现还是相当可以的。上线两周后,我统计过推荐模块给活动带来的点击占比,大约能占到首页总点击的60%左右。对于一个完全不依赖外部数据的轻推荐场景,这个效果已经足够让社团那边满意了。4. 活动管理链路:从创建报名到自动完结4.1 核心表结构设计与状态流转活动管理是系统的地基,地基不稳,推荐模块再花哨也白搭。我设计活动表时,重点关注的不只是字段全不全,而是状态流转清不清楚。活动表的核心字段大概包括:CREATE TABLE `activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '活动标题', `cover_url` VARCHAR(255) DEFAULT NULL COMMENT '封面图地址', `category` VARCHAR(20) NOT NULL COMMENT '媒体类别', `description` TEXT COMMENT '活动简介', `start_time` DATETIME NOT NULL COMMENT '开始时间', `end_time` DATETIME NOT NULL COMMENT '结束时间', `location` VARCHAR(100) COMMENT '活动地点', `total_slots` INT DEFAULT 0 COMMENT '报名总名额', `signed_up_count` INT DEFAULT 0 COMMENT '已报名人数', `status` TINYINT DEFAULT 0 COMMENT '0草稿 1报名中 2进行中 3已结束 4已取消', `recommend_weight` DOUBLE DEFAULT 1.0 COMMENT '推荐加权系数', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category`, `status`), KEY `idx_start_time` (`start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='媒体活动主表';这里的status字段是活动生命周期的核心。我的做法是不允许前端随意改状态,所有状态变更必须走后端接口。因为状态直接影响着前端按钮的显示逻辑、推荐算法的可推荐性、以及报名名额的校验,状态失控会引发一连串连锁问题。4.2 定时任务处理活动状态自动流转活动状态里最容易忽略的是“报名中→进行中→已结束”的自动流转。如果全靠管理员手工换状态,忘一次就会导致用户还能报名已经结束的活动,或者活动明明在举办却没有显示“进行中”的标签。我用了Spring Task的定时任务来做这件事:@Component public class ActivityStatusScheduler { @Scheduled(cron = "0 */5 * * * ?") public void updateActivityStatus() { Date now = new Date(); // 报名截止:end_time < now 且 status=1 -> status=2 activityMapper.updateSignUpToOngoing(now); // 活动结束:end_time + 2小时 < now 且 status=2 -> status=3 activityMapper.updateOngoingToFinished(now); } }这里有几个细节值得说明。报名截止和活动结束不是同一个时间点,比如某个影视放映活动18:00开始,报名截止时间往往提前几小时。我在表里加了一个signup_deadline字段,报名状态判断用这个字段,而不是直接用end_time。另外一个细节是,活动结束后不要立刻把所有状态变成已结束,我会留一个2小时的缓冲期,让管理员有时间处理现场突发情况和后台数据核对。4.3 报名与收藏的并发处理报名功能是这个系统里并发压力最集中的点。热门活动一开放报名,如果活动方的宣传文案足够吸引人,瞬间可能涌进几百个请求。如果直接在应用层做signed_up_count < total_slots判断,然后update加一,很容易出现超卖。我的方案是两段式校验:第一段,在Redis里用原子自增操作预占名额。每次报名请求先执行INCR activity_signup_${activityId},判断是否超过total_slots。超了就返回“名额已满”,没超再执行数据库层面的正式报名。第二段,在数据库里做唯一约束兜底,比如user_id + activity_id建唯一索引,防止同一个用户重复报名。这样即使Redis出现极端情况,数据库也能挡住重复数据。实际操作中这个方案的体验比纯数据库乐观锁好很多,因为Redis自增操作是O(1),压测下基本没有性能瓶颈。如果活动太火导致Redis里的计数和数据库不一致,也不用慌,定时任务会在凌晨进行一次数据对齐补偿。5. 前端交互:推荐位、活动卡片与筛选维度5.1 首页推荐位设计与数据来源首页是整个系统的门面,也是用户停留时间最长的位置。我的首页分三块:顶部轮播图推荐位、中部个性化推荐流、底部活动分类入口。轮播图推荐位的数据来源不是人工指定的图片,而是从活动表里筛选出recommend_weight较高且状态在“报名中”的活动,取前五条。这样做的好处是推荐位永远跟着活动数据走,不用每次上线新活动都去后台手动换轮播图。个性化推荐流是首页的核心,我调用后端接口/api/recommend/activities?userId=xxx&limit=10。这个接口返回的不是简单的活动列表,而是一个包含推荐原因的对象。比如返回“因为你喜欢戏剧,为你推荐这部话剧”,这种解释性推荐的效果远比冷冰冰的“猜你喜欢”四个字好。前端拿到推荐原因后,会展示在卡片标题下方。5.2 活动列表的筛选与排序逻辑活动列表页承担的是“搜索”场景,用户在这里带明确意图找活动。筛选维度我做了四个:类别筛选、时间筛选、地点筛选、状态筛选。其中地点筛选是下拉选项,数据由管理员在后台维护一个场所字典表,前端直接拉字典接口渲染。排序逻辑上,我提供了三个Tab:推荐排序、最新发布、即将开始。“推荐排序”复用推荐模块的接口,保证用户在列表页也能看到个性化内容;“最新发布”按create_time倒序;“即将开始”按start_time正序,并过滤掉已经结束的活动。这里有一个经验之谈:筛选条件不要全做在前端,最好通过URL参数传给后端。比如用户选了“音乐”类别,传的是/activity/list?category=music&sort=recommend。这样用户分享链接给朋友时,对方打开还能看到相同的筛选结果,而不是重新回到默认页。5.3 详情页与报名状态的联动活动详情页是转化率最关键的一环。我在设计时重点处理了按钮状态的联动:活动未开始且未到报名截止:显示“立即报名”按钮。活动已报满:按钮变灰色,文案改成“已报满”。用户已报名:按钮换成“取消报名”和“我的票券”两个入口。活动状态为“进行中”:按钮隐藏,改成“活动正在举办”。活动“已结束”或“已取消”:页面顶部显示灰色状态条。这个联动看起来简单,但很多项目会忽略一个细节:用户报名状态需要和后端实时校验。如果只靠前端判断,用户打开详情页时显示“立即报名”,但报名过程中名额被抢完,后端就会返一个“名额已满”的异常。我在前端做了全局错误提示,并在报名按钮处做了防重复点击处理,避免用户急躁连点导致产生多条无效请求。6. 权限模型:普通用户、组织者、管理员到底谁说了算6.1 三套角色的菜单与操作边界这个系统的用户角色,我一开始设计了两级,管理员和普通用户。但真正梳理业务后发现,社团干事其实需要的是一个更细的“活动组织者”角色。这个角色只能管理自己发布的活动,不能改别人的,更不能动系统配置。权限模型切成三级会更清晰:角色可访问范围典型操作普通用户首页推荐、活动列表、活动详情浏览、收藏、报名、取消报名、查看个人中心活动组织者自建活动管理、报名名单导出发布活动、编辑活动、查看参与者名单、手动结束活动管理员全站内容、全量用户、系统设置用户禁用、活动审核、分类管理、推荐参数调整这个表格看着简单,落地到后端就有很多细节。比如活动组织者编辑活动时,前端可能传到任意一个活动ID,后端必须校验当前用户是否为该活动的创建者,否则就是一个越权漏洞。这类校验不能用简单的GET参数判断,必须在Service层状态里面做一次查询。6.2 后端鉴权的具体实现鉴权我用的是JWT加Spring Security的组合。整体流程是:用户登录成功后,后端签发一个包含用户ID和角色信息的JWT令牌,前端存在localStorage里,每次请求Headers带Authorization: Bearer xxxx。Spring Security里我需要重写两个关键部分:一是自定义UserDetailsService,从数据库加载用户信息;二是自定义OncePerRequestFilter,在请求进来时解析JWT并设置用户上下文。角色权限用注解控制,比如组织者独有的操作加@PreAuthorize("hasRole('ORGANIZER')"),管理员接口加@PreAuthorize("hasRole('ADMIN')")。这里有一个特别容易踩的坑:Spring Security默认的会话机制是Session-based,如果你不加配置,它仍然会去创建HttpSession。前后端分离项目里,Session的存在反而会带来跨域和CSRF的一堆麻烦。我在SecurityConfig里继承了WebSecurityConfigurerAdapter,把所有Session策略改成SessionCreationPolicy.STATELESS,彻底关闭服务端会话。6.3 越权问题排查的复盘上线前我做了一轮简单的越权自查,结果查出一个问题,这里也特别提醒大家注意。活动删除接口当时没有做归属校验,导致组织者A可以删除组织者B的活动。我当初想的是“组织者就是管理自己活动的”,所以controller里只校验了角色,没校验资源归属。修复方案其实不复杂,就是在删除接口的业务逻辑里增加一条:Activity activity = activityMapper.selectById(activityId); if (activity == null || !activity.getCreatorId().equals(currentUserId)) { throw new BusinessException("无权操作该活动"); }这么一改,接口就从“角色级权限”升级成了“资源级权限”。这事的教训是,权限控制不能只停留在“谁能进这个接口”,还要回答“谁能操作这条数据”。后一个问题才是越权漏洞的高发区。7. 打包部署与上线后的真实体会7.1 前后端分离部署的静态资源处理项目开发完成之后,面临的就是部署问题。这个系统最终部署在一台4核8G的云主机上,后端用Maven打成jar包运行,前端Vue项目npm run build后生成dist目录,交给Nginx托管。这里有个关键点:前端打包出的dist目录本质上是一堆静态文件,而后端接口跑在8080端口。用户访问的是Nginx的80端口,Nginx需要把/api/开头的请求反向代理到后端服务,把其他请求指向dist目录。Nginx配置片段大概长这样:server { listen 80; server_name media.example.com; root /var/www/media/dist; index index.html; location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这个配置里的try_files $uri $uri/ /index.html非常重要。没有它,用户刷新/activity/detail/12这个页面时,Nginx会去找真实的/activity/detail/12路径,结果404。加上这行,Nginx会把所有找不到的前端路由都送回index.html,由Vue Router接管跳转。7.2 全链路压测遇到的两个问题系统上线前,我用压测工具做了简单的并发测试。并发200个用户同时访问首页,后端接口的平均响应时间大概在120毫秒左右,这个成绩勉强合格,但压测过程中暴露了两个问题,第二个还蛮隐蔽的。第一个问题是数据库连接池溢出。SpringBoot默认的HikariCP连接池最大连接数是10,在并发200的场景下,瞬间请求把连接池打满了,部分请求直接报错。解决方法是调整连接池参数,maximum-pool-size从10调到50,同时给每个业务接口做好事务边界,避免长事务占着连接不放。第二个问题是首页未登录状态的频繁查询。未登录用户没有userID,推荐接口会走兜底策略,每次兜底都要执行一次多表关联的热门活动查询。我加了缓存,推荐接口先查Redis,缓存有就直接返回,没有才查数据库并回填缓存。缓存过期时间设置5分钟,实测压测后首页接口的QPS提升了一倍多。7.3 我复盘后最想提醒新手的三个点项目收尾的时候,我整理了一份简单的复盘笔记,挑三条最值得分享的给大家。第一,推荐系统不要一上来就上协同过滤。协同过滤需要大量的行为数据,冷启动阶段效果非常差。先让用户填兴趣标签,再结合行为权重打分,这已经足够应付中小规模的推荐场景了。第二,前后端联调时接口一定要定义清楚统一返回格式。我的建议是全部用{ code: 200, message: "success", data: {} }这样的结构,业务异常也走这个格式,而不是直接返回一个裸字符串。后续加全局异常处理器时,这种规范会省非常多事。第三,不要把项目想成“写完就结束”。推荐模块调参、权限检查、部署脚本这些看似不显眼的“非功能需求”,最后花的时间加起来跟写业务代码差不多。提前规划好时间,就不会在截止日期前手忙脚乱。做这个系统最大的感受是,媒体活动推荐系统本身的业务不算复杂,但它把前后端技术栈、推荐策略、权限设计、部署运维这些点完整串了一遍,是一个非常适合用来练手并且能做出成果的综合性项目。如果你也在做类似的系统,建议先花一周时间把场景想透,再动手写代码,这会比拿到需求就开始建表好用得多。
媒体活动推荐系统这个题目,听起来像是“活动报名网站加了个热门列表”,但你真正动手做的时候会发现,最难的部分根本不是增删改查,而是“推荐”这两个字怎么落地。这篇聊聊我基于SpringBoot + Vue做的一个媒体活动推荐系统,从场景拆解、推荐策略、权限设计到部署上线的完整过程,重点讲清楚每一步为什么这么做,踩过哪些坑,希望能给正在做同类型项目的同学一个相对完整的参考。1. 这个系统到底在解决什么问题1.1 “推荐”不等于“随机洗脸”很多新手拿到这个标题,第一反应是:活动表里按时间排序,再根据点击量排个热门榜,这不就是推荐吗?我的建议是先把这句话忘掉。真正的媒体活动推荐系统,面对的是信息过载的问题——活动数量一旦超过两三百条,用户基本上只会看前三屏,后面再丰富的内容都沉底了。这时候,把最匹配用户兴趣的活动顶到前面,才算推荐。我做这个项目的起因,是某高校的社团联合会想统一管理校内外的文体活动。他们原先靠公众号推文和群消息转发,活动分散在多个渠道,用户根本不知道有哪些活动。系统的核心需求就三条:第一,活动信息集中展示;第二,每个用户打开首页看到的推荐内容不一样;第三,管理员能发布活动、审核内容、查看报名数据。这三条里,第一条和第三条是标准CRUD,第二条才是项目真正的技术含量所在。1.2 场景还原:谁会用什么设备来看什么内容在动手写代码之前,我花了很长时间梳理用户场景。这个系统的用户大致分四类:在校学生:关心周末有什么演出、展览、比赛,愿意报名的活动越少越好,希望首页直接推对口内容。社团干事:需要发布活动、管理报名名单,更关注后台操作的效率,而不是推荐效果。活动赞助方:偶尔登录查看某个活动的浏览量和报名转化,对权限隔离有要求。系统管理员:负责账号管理、内容审核、数据统计,需要全局视角。这四类用户的诉求不一样,意味着同一个页面不能只有一种展示逻辑。推荐位只对普通用户生效,管理后台则是功能菜单,压根不用推荐算法。这个区分看似简单,但很多类似项目第一版就把首页做成一刀切,结果后台管理员也看到一堆“猜你喜欢”,体验非常怪异。1.3 我最终圈定的三个核心模块基于场景还原,我把系统拆成三个相对独立的模块:一是活动信息模块,包含活动的创建、编辑、上下架、分类标签、封面图、时间地点、报名名额等基础信息;二是推荐模块,根据用户的历史行为和兴趣标签,计算活动推荐分数;三是管理模块,包含用户管理、活动审核、报名管理、数据看板。这三个模块在数据库层面可以共用活动表和用户表,但业务逻辑上必须解耦。我的做法是,推荐模块单独写一个服务类,不直接操作活动表的Mapper,而是通过一个推荐服务接口去读取活动快照。这样后续想换更复杂的推荐算法,不需要动活动管理的代码。2. 为什么选SpringBoot + Vue,而不是别的组合2.1 后端选型:Java生态下的快速交付与稳定背书SpringBoot在这个项目里的角色,不只是提供Web接口,它把整个后端的技术栈统一成了一个东西。我的选型逻辑主要有三点:第一,SpringBoot的自动配置能极大减少搭建成本。项目里用到的MySQL、Redis、JWT鉴权、定时任务,都是通过starter一键引入,不需要像传统SSM那样手工写一堆XML配置。对于这种以业务逻辑为主的系统,开发效率是第一位的。第二,Spring的生态够稳。推荐模块虽然我第一版用的是标签打分,但后续完全有可能接更复杂的数据。在Spring生态下,换算法只是替换Service实现类的问题,接口层不用动。第三,团队协作成本低。Java的强类型约束让前后端联调时不容易扯皮。接口文档一出来,字段名对不上这种低级错误基本不会发生,因为编译期就能发现。2.2 前端选型:Vue组件化带来的页面复用Vue这边,我看中的是组件复用能力和渐进式接入的特性。这个系统的页面虽然不算多,但活动卡片、标签筛选、轮播推荐位、报名弹窗这些组件会在多个页面重复出现。比如首页有推荐活动卡片,活动列表页也有同样的卡片,只不过数据来源不同。如果用传统模板引擎,遇上这种需求要么复制粘贴,要么用include,后期改一个卡片样式要改好几处。Vue里我把ActivityCard做成了全局组件,传入一个activity对象,内部自己处理封面、标签、状态、报名按钮的联动,任何页面引入就能用。前端除了Vue2/Vue3本身,我还加了Vue Router和Pinia。Vue Router处理页面跳转和路由守卫,Pinia存用户登录状态和兴趣标签。没有上Element UI全家桶,因为活动卡片这类自定义UI比较强,UI框架的表格和表单组件够用就行,过度依赖框架反而让页面长得千篇一律。2.3 前后端分离的分层逻辑前后端分离是我刻意坚持的,因为这个项目未来必然要扩展成多端。接口层按RESTful风格设计,前端只和接口打交道。后端的包结构大概是这样:com.example.mediaevent ├── controller // Web层,接收请求 ├── service // 业务层,包含推荐服务 │ └── recommend // 推荐模块独立子包 ├── mapper // 数据访问层 ├── entity // 实体类 ├── config // 配置类,包括安全配置 └── common // 统一返回、异常处理等这个分包的核心思想是,推荐模块的代码不要散落到活动的CRUD逻辑里。我见过不少人把推荐算法直接写在Controller里,一百多行业务代码挤一个方法,后续想改算法得把整个Controller翻一遍。独立分包之后,推荐服务只需要对上层暴露一个recommendActivities(Long userId, int limit)方法,内部逻辑随时可以优化。3. 推荐模块落地:我不卷模型,只做三件事3.1 用户兴趣画像的建立方式推荐系统的第一步,是得知道用户喜欢什么。在这个系统里,我采用“显式画像加隐式行为”的组合方案。显式画像来自用户注册时选择的兴趣标签。我预置了音乐、舞蹈、戏剧、体育、展览、讲座、影视、公益这八个媒体活动类别,用户至少选三个。这里有一个设计细节:注册时标签不要超过八个,因为选项太多会加重用户决策负担,实际勾选率会明显下降。隐式行为来自用户的操作记录。我定义了一套行为权重体系:行为类型行为说明默认权重BROWSE浏览活动详情1.0COLLECT收藏活动3.0SIGN_UP报名活动5.0SHARE转发分享4.0CANCEL取消报名-2.0用户每产生一个行为,后台会异步写入行为日志表,定时任务每小时汇总一次,更新用户的标签偏好分数。这个设计的好处是,用户第一次登录时靠显式画像推荐,随着行为积累,隐式画像的权重逐渐超过初始标签,推荐结果就越来越精准了。3.2 活动标签打分与相似度计算有了画像之后,怎么把用户和活动匹配起来?我用的不是复杂的协同过滤,而是一个简化版的标签空间余弦相似度。每个用户维护一个兴趣向量,每个活动维护一个标签向量。向量维度就是这些媒体类别。比如一个用户平时喜欢看戏剧和音乐,那向量可能是:{戏剧: 0.75, 音乐: 0.6, 影视: 0.3, ...}。活动发布时也会打标签向量,比如一部话剧演出,标签向量就是{戏剧: 0.9, 其他: 0.1}。计算匹配分数时,用余弦相似度公式:similarity = (v1·v2) / (|v1| × |v2|)其中v1是用户向量,v2是活动向量。向量点积越大,说明两个向量在方向上越接近,也就是用户越可能喜欢这个活动。代码实现大概是这样:public double calculateSimilarity(Map<String, Double> userVector, Map<String, Double> activityVector) { double dotProduct = 0.0; double userNorm = 0.0; double activityNorm = 0.0; for (String tag : userVector.keySet()) { Double userWeight = userVector.getOrDefault(tag, 0.0); Double activityWeight = activityVector.getOrDefault(tag, 0.0); dotProduct += userWeight * activityWeight; userNorm += userWeight * userWeight; } for (Double weight : activityVector.values()) { activityNorm += weight * weight; } if (userNorm == 0.0 || activityNorm == 0.0) { return 0.0; } return dotProduct / (Math.sqrt(userNorm) * Math.sqrt(activityNorm)); }计算完之后,用相似度乘以一个时间衰减因子,再加上基础权重,得到最终排序分。时间衰减因子我用的是exp(-ageDays / 30),意思是活动发布越久,推荐优先级越低,避免老活动一直霸榜。3.3 冷启动和新活动上线的兜底策略冷启动是所有推荐系统绕不开的问题,主要分两种情况。第一种是用户冷启动,也就是新用户没有行为数据。我的方案是注册时选的兴趣标签直接作为初始向量,如果连标签都没选,就返回当前时间段的默认推荐——比如周末优先推演出类,工作日优先推讲座展览类。另一个思路是参考地理位置,优先推荐离用户近的活动,但第一版我没做LBS,只按时间段和活动热度兜底。第二种是活动冷启动,也就是新发布的活动还没人报名,没有点击量,很容易被推荐算法忽略。我加了一个保底机制:新活动在上架后24小时内会获得一个基础加分,不受热度因子影响。这个时间窗口很关键,长于24小时容易导致三天的垃圾活动一直冒头,短了又给不到足够曝光。实际操作中,这套推荐策略的表现还是相当可以的。上线两周后,我统计过推荐模块给活动带来的点击占比,大约能占到首页总点击的60%左右。对于一个完全不依赖外部数据的轻推荐场景,这个效果已经足够让社团那边满意了。4. 活动管理链路:从创建报名到自动完结4.1 核心表结构设计与状态流转活动管理是系统的地基,地基不稳,推荐模块再花哨也白搭。我设计活动表时,重点关注的不只是字段全不全,而是状态流转清不清楚。活动表的核心字段大概包括:CREATE TABLE `activity` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '活动标题', `cover_url` VARCHAR(255) DEFAULT NULL COMMENT '封面图地址', `category` VARCHAR(20) NOT NULL COMMENT '媒体类别', `description` TEXT COMMENT '活动简介', `start_time` DATETIME NOT NULL COMMENT '开始时间', `end_time` DATETIME NOT NULL COMMENT '结束时间', `location` VARCHAR(100) COMMENT '活动地点', `total_slots` INT DEFAULT 0 COMMENT '报名总名额', `signed_up_count` INT DEFAULT 0 COMMENT '已报名人数', `status` TINYINT DEFAULT 0 COMMENT '0草稿 1报名中 2进行中 3已结束 4已取消', `recommend_weight` DOUBLE DEFAULT 1.0 COMMENT '推荐加权系数', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category`, `status`), KEY `idx_start_time` (`start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='媒体活动主表';这里的status字段是活动生命周期的核心。我的做法是不允许前端随意改状态,所有状态变更必须走后端接口。因为状态直接影响着前端按钮的显示逻辑、推荐算法的可推荐性、以及报名名额的校验,状态失控会引发一连串连锁问题。4.2 定时任务处理活动状态自动流转活动状态里最容易忽略的是“报名中→进行中→已结束”的自动流转。如果全靠管理员手工换状态,忘一次就会导致用户还能报名已经结束的活动,或者活动明明在举办却没有显示“进行中”的标签。我用了Spring Task的定时任务来做这件事:@Component public class ActivityStatusScheduler { @Scheduled(cron = "0 */5 * * * ?") public void updateActivityStatus() { Date now = new Date(); // 报名截止:end_time < now 且 status=1 -> status=2 activityMapper.updateSignUpToOngoing(now); // 活动结束:end_time + 2小时 < now 且 status=2 -> status=3 activityMapper.updateOngoingToFinished(now); } }这里有几个细节值得说明。报名截止和活动结束不是同一个时间点,比如某个影视放映活动18:00开始,报名截止时间往往提前几小时。我在表里加了一个signup_deadline字段,报名状态判断用这个字段,而不是直接用end_time。另外一个细节是,活动结束后不要立刻把所有状态变成已结束,我会留一个2小时的缓冲期,让管理员有时间处理现场突发情况和后台数据核对。4.3 报名与收藏的并发处理报名功能是这个系统里并发压力最集中的点。热门活动一开放报名,如果活动方的宣传文案足够吸引人,瞬间可能涌进几百个请求。如果直接在应用层做signed_up_count < total_slots判断,然后update加一,很容易出现超卖。我的方案是两段式校验:第一段,在Redis里用原子自增操作预占名额。每次报名请求先执行INCR activity_signup_${activityId},判断是否超过total_slots。超了就返回“名额已满”,没超再执行数据库层面的正式报名。第二段,在数据库里做唯一约束兜底,比如user_id + activity_id建唯一索引,防止同一个用户重复报名。这样即使Redis出现极端情况,数据库也能挡住重复数据。实际操作中这个方案的体验比纯数据库乐观锁好很多,因为Redis自增操作是O(1),压测下基本没有性能瓶颈。如果活动太火导致Redis里的计数和数据库不一致,也不用慌,定时任务会在凌晨进行一次数据对齐补偿。5. 前端交互:推荐位、活动卡片与筛选维度5.1 首页推荐位设计与数据来源首页是整个系统的门面,也是用户停留时间最长的位置。我的首页分三块:顶部轮播图推荐位、中部个性化推荐流、底部活动分类入口。轮播图推荐位的数据来源不是人工指定的图片,而是从活动表里筛选出recommend_weight较高且状态在“报名中”的活动,取前五条。这样做的好处是推荐位永远跟着活动数据走,不用每次上线新活动都去后台手动换轮播图。个性化推荐流是首页的核心,我调用后端接口/api/recommend/activities?userId=xxx&limit=10。这个接口返回的不是简单的活动列表,而是一个包含推荐原因的对象。比如返回“因为你喜欢戏剧,为你推荐这部话剧”,这种解释性推荐的效果远比冷冰冰的“猜你喜欢”四个字好。前端拿到推荐原因后,会展示在卡片标题下方。5.2 活动列表的筛选与排序逻辑活动列表页承担的是“搜索”场景,用户在这里带明确意图找活动。筛选维度我做了四个:类别筛选、时间筛选、地点筛选、状态筛选。其中地点筛选是下拉选项,数据由管理员在后台维护一个场所字典表,前端直接拉字典接口渲染。排序逻辑上,我提供了三个Tab:推荐排序、最新发布、即将开始。“推荐排序”复用推荐模块的接口,保证用户在列表页也能看到个性化内容;“最新发布”按create_time倒序;“即将开始”按start_time正序,并过滤掉已经结束的活动。这里有一个经验之谈:筛选条件不要全做在前端,最好通过URL参数传给后端。比如用户选了“音乐”类别,传的是/activity/list?category=music&sort=recommend。这样用户分享链接给朋友时,对方打开还能看到相同的筛选结果,而不是重新回到默认页。5.3 详情页与报名状态的联动活动详情页是转化率最关键的一环。我在设计时重点处理了按钮状态的联动:活动未开始且未到报名截止:显示“立即报名”按钮。活动已报满:按钮变灰色,文案改成“已报满”。用户已报名:按钮换成“取消报名”和“我的票券”两个入口。活动状态为“进行中”:按钮隐藏,改成“活动正在举办”。活动“已结束”或“已取消”:页面顶部显示灰色状态条。这个联动看起来简单,但很多项目会忽略一个细节:用户报名状态需要和后端实时校验。如果只靠前端判断,用户打开详情页时显示“立即报名”,但报名过程中名额被抢完,后端就会返一个“名额已满”的异常。我在前端做了全局错误提示,并在报名按钮处做了防重复点击处理,避免用户急躁连点导致产生多条无效请求。6. 权限模型:普通用户、组织者、管理员到底谁说了算6.1 三套角色的菜单与操作边界这个系统的用户角色,我一开始设计了两级,管理员和普通用户。但真正梳理业务后发现,社团干事其实需要的是一个更细的“活动组织者”角色。这个角色只能管理自己发布的活动,不能改别人的,更不能动系统配置。权限模型切成三级会更清晰:角色可访问范围典型操作普通用户首页推荐、活动列表、活动详情浏览、收藏、报名、取消报名、查看个人中心活动组织者自建活动管理、报名名单导出发布活动、编辑活动、查看参与者名单、手动结束活动管理员全站内容、全量用户、系统设置用户禁用、活动审核、分类管理、推荐参数调整这个表格看着简单,落地到后端就有很多细节。比如活动组织者编辑活动时,前端可能传到任意一个活动ID,后端必须校验当前用户是否为该活动的创建者,否则就是一个越权漏洞。这类校验不能用简单的GET参数判断,必须在Service层状态里面做一次查询。6.2 后端鉴权的具体实现鉴权我用的是JWT加Spring Security的组合。整体流程是:用户登录成功后,后端签发一个包含用户ID和角色信息的JWT令牌,前端存在localStorage里,每次请求Headers带Authorization: Bearer xxxx。Spring Security里我需要重写两个关键部分:一是自定义UserDetailsService,从数据库加载用户信息;二是自定义OncePerRequestFilter,在请求进来时解析JWT并设置用户上下文。角色权限用注解控制,比如组织者独有的操作加@PreAuthorize("hasRole('ORGANIZER')"),管理员接口加@PreAuthorize("hasRole('ADMIN')")。这里有一个特别容易踩的坑:Spring Security默认的会话机制是Session-based,如果你不加配置,它仍然会去创建HttpSession。前后端分离项目里,Session的存在反而会带来跨域和CSRF的一堆麻烦。我在SecurityConfig里继承了WebSecurityConfigurerAdapter,把所有Session策略改成SessionCreationPolicy.STATELESS,彻底关闭服务端会话。6.3 越权问题排查的复盘上线前我做了一轮简单的越权自查,结果查出一个问题,这里也特别提醒大家注意。活动删除接口当时没有做归属校验,导致组织者A可以删除组织者B的活动。我当初想的是“组织者就是管理自己活动的”,所以controller里只校验了角色,没校验资源归属。修复方案其实不复杂,就是在删除接口的业务逻辑里增加一条:Activity activity = activityMapper.selectById(activityId); if (activity == null || !activity.getCreatorId().equals(currentUserId)) { throw new BusinessException("无权操作该活动"); }这么一改,接口就从“角色级权限”升级成了“资源级权限”。这事的教训是,权限控制不能只停留在“谁能进这个接口”,还要回答“谁能操作这条数据”。后一个问题才是越权漏洞的高发区。7. 打包部署与上线后的真实体会7.1 前后端分离部署的静态资源处理项目开发完成之后,面临的就是部署问题。这个系统最终部署在一台4核8G的云主机上,后端用Maven打成jar包运行,前端Vue项目npm run build后生成dist目录,交给Nginx托管。这里有个关键点:前端打包出的dist目录本质上是一堆静态文件,而后端接口跑在8080端口。用户访问的是Nginx的80端口,Nginx需要把/api/开头的请求反向代理到后端服务,把其他请求指向dist目录。Nginx配置片段大概长这样:server { listen 80; server_name media.example.com; root /var/www/media/dist; index index.html; location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这个配置里的try_files $uri $uri/ /index.html非常重要。没有它,用户刷新/activity/detail/12这个页面时,Nginx会去找真实的/activity/detail/12路径,结果404。加上这行,Nginx会把所有找不到的前端路由都送回index.html,由Vue Router接管跳转。7.2 全链路压测遇到的两个问题系统上线前,我用压测工具做了简单的并发测试。并发200个用户同时访问首页,后端接口的平均响应时间大概在120毫秒左右,这个成绩勉强合格,但压测过程中暴露了两个问题,第二个还蛮隐蔽的。第一个问题是数据库连接池溢出。SpringBoot默认的HikariCP连接池最大连接数是10,在并发200的场景下,瞬间请求把连接池打满了,部分请求直接报错。解决方法是调整连接池参数,maximum-pool-size从10调到50,同时给每个业务接口做好事务边界,避免长事务占着连接不放。第二个问题是首页未登录状态的频繁查询。未登录用户没有userID,推荐接口会走兜底策略,每次兜底都要执行一次多表关联的热门活动查询。我加了缓存,推荐接口先查Redis,缓存有就直接返回,没有才查数据库并回填缓存。缓存过期时间设置5分钟,实测压测后首页接口的QPS提升了一倍多。7.3 我复盘后最想提醒新手的三个点项目收尾的时候,我整理了一份简单的复盘笔记,挑三条最值得分享的给大家。第一,推荐系统不要一上来就上协同过滤。协同过滤需要大量的行为数据,冷启动阶段效果非常差。先让用户填兴趣标签,再结合行为权重打分,这已经足够应付中小规模的推荐场景了。第二,前后端联调时接口一定要定义清楚统一返回格式。我的建议是全部用{ code: 200, message: "success", data: {} }这样的结构,业务异常也走这个格式,而不是直接返回一个裸字符串。后续加全局异常处理器时,这种规范会省非常多事。第三,不要把项目想成“写完就结束”。推荐模块调参、权限检查、部署脚本这些看似不显眼的“非功能需求”,最后花的时间加起来跟写业务代码差不多。提前规划好时间,就不会在截止日期前手忙脚乱。做这个系统最大的感受是,媒体活动推荐系统本身的业务不算复杂,但它把前后端技术栈、推荐策略、权限设计、部署运维这些点完整串了一遍,是一个非常适合用来练手并且能做出成果的综合性项目。如果你也在做类似的系统,建议先花一周时间把场景想透,再动手写代码,这会比拿到需求就开始建表好用得多。