ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot旅游推荐系统毕设实战:协同过滤、WebSocket与日志模块全解析

SpringBoot旅游推荐系统毕设实战:协同过滤、WebSocket与日志模块全解析 1. 项目定位与功能模块拆解毕设选这题值不值先交代一下背景。我去年带过几个学生做毕设亲眼看见基于springboot的旅游推荐系统这类题目在选题系统里有多抢手——一个班四十多人撞题的有七八个。但有意思的是最后答辩分数拉开差距的恰恰不是谁的界面好看而是谁把推荐系统到底怎么推荐这件事讲明白了。你这套题目的全称是基于SpringBoot的旅游推荐系统包含在线沟通、协同过滤、日志记录、优惠券、积分五个模块。说实话这个题目的容量放在本科毕设里属于中等偏上核心难点不在CRUD而在协同过滤算法的落地和多模块之间的数据流转设计。选这个题如果只是把增删改查堆起来答辩时老师一句你这个推荐和普通列表查询有什么区别就能把你问住。所以这篇博文我会把每个模块从设计到实现的关键节点全部过一遍顺便把我试过的坑和优化方案说出来希望能帮你少走两周弯路。先看整体功能规划。我做项目从来是先画业务边界再写代码不然做着做着就失控了。你拿到的题目可以拆成四层层级模块核心职责表现层Vue前端 管理后台用户交互、景点展示、下单操作接口层SpringBoot RESTful API业务逻辑入口、权限校验、参数校验业务层推荐引擎、订单、积分、优惠券、聊天核心业务规则处理数据层MySQL Redis 日志表持久化、缓存、操作留痕这里有一个容易被忽略的设计点在线沟通模块是独立于推荐系统之外的伴生模块它不是旅游业务的必需流程而是为了提升用户粘性而存在的。这就意味着它的业务规则和订单、积分没有强耦合可以单独设计表结构和接口。协同过滤推荐模块则相反它依赖用户的浏览记录、收藏记录、下单记录来生成评分数据是整个系统里数据流向最复杂的部分。我的建议是开发顺序按基础CRUD - 日志 - 积分优惠券 - 协同过滤 - 在线沟通推进。理由很简单前三个模块帮你把SpringBoot的基础能力练熟协同过滤需要一定的算法基础放在中间做有挑战也有时间余量在线沟通是工作量最大但技术难度相对可控的模块放最后冲刺。2. 协同过滤怎么落地从相似度公式到缓存策略2.1 基于用户的协同过滤UserCF为什么是毕设首选协同过滤是推荐系统的老牌算法分两大类基于用户的UserCF和基于物品的ItemCF。在旅游推荐这个场景里我强烈建议毕设选择基于用户的协同过滤理由有三第一旅游景点数量远小于用户数量时ItemCF要维护景点之间的相似度矩阵矩阵规模等于景点数的平方数据量不够看。而UserCF的相似度矩阵是用户之间的一个几百用户的系统足够撑起效果演示。第二旅游消费决策有明显的同好聚集特征——喜欢爬山的用户群体和喜欢逛博物馆的用户群体重叠度很低基于用户的相似推荐更容易解释清楚。你可以直接说和你相似的用户还去了这些景点答辩逻辑顺畅。第三UserCF算法在离线计算后可以缓存结果实时请求只需要查Redis技术选型上更好讲故事。2.2 评分数据从哪来显式评分与隐式行为加权真实的推荐系统不会让用户挨个给景点打分那体验太糟糕了。毕设里最合理的做法是混合评分策略让隐式行为占大头评分矩阵 浏览记录 × 0.2 收藏记录 × 0.3 下单支付 × 0.5这个权重的意义在于用户浏览了一个景点说明有兴趣收藏了说明意愿更强下单了说明转化完成。用这三类行为加权组合出伪评分效果比让用户打星要好得多而且你的数据库表里天然就有这些数据不用额外造。具体实现上我在项目里设计了一张user_behavior_log表记录用户的行为类型和对象ID。然后在计算评分矩阵时用一条SQL聚合出用户-景点-分数三元组SELECT user_id, scenic_id, SUM(CASE WHEN behavior_type VIEW THEN 0.2 WHEN behavior_type FAVORITE THEN 0.3 WHEN behavior_type ORDER THEN 0.5 END) AS score FROM user_behavior_log GROUP BY user_id, scenic_id;这条SQL跑完你就得到了一个稀疏矩阵。稀疏没关系协同过滤天生就是处理稀疏矩阵的。2.3 相似度计算与推荐生成的完整实现相似度计算用余弦相似度就够但要注意旅游场景的用户行为向量是0/1或者0到1之间的稀疏向量直接用余弦公式算出来的相似度会偏小。我测试下来使用调整余弦相似度Adjusted Cosine在旅游场景效果更稳定因为它减去了用户平均评分消除了用户打分尺度差异。核心代码长这样public class UserCFRecommender { /** * 计算两个用户之间的调整余弦相似度 * param scores 用户-景点评分矩阵 * param user1 用户1 * param user2 用户2 * return 相似度[-1, 1]-1表示完全不同1表示完全相似 */ public double adjustedCosineSimilarity(MapInteger, MapLong, Double scores, Integer user1, Integer user2) { MapLong, Double user1Scores scores.get(user1); MapLong, Double user2Scores scores.get(user2); if (user1Scores null || user2Scores null) return 0.0; // 找出两个用户共同评分过的景点 SetLong commonItems new HashSet(user1Scores.keySet()); commonItems.retainAll(user2Scores.keySet()); if (commonItems.isEmpty()) return 0.0; // 计算两个用户各自的平均评分 double user1Avg user1Scores.values().stream().mapToDouble(d - d).average().orElse(0.0); double user2Avg user2Scores.values().stream().mapToDouble(d - d).average().orElse(0.0); double numerator 0.0; double denom1 0.0; double denom2 0.0; for (Long item : commonItems) { double r1 user1Scores.get(item) - user1Avg; double r2 user2Scores.get(item) - user2Avg; numerator r1 * r2; denom1 r1 * r1; denom2 r2 * r2; } if (denom1 0.0 || denom2 0.0) return 0.0; return numerator / (Math.sqrt(denom1) * Math.sqrt(denom2)); } /** * 为目标用户生成TopN推荐 * param scores 评分矩阵 * param targetUserId 目标用户 * param neighbors 每个用户的最相似邻居集合 * param topN 推荐数量 * return 推荐的景点ID列表 */ public ListLong recommend(MapInteger, MapLong, Double scores, Integer targetUserId, MapInteger, ListUserSimilarity neighbors, int topN) { MapLong, Double targetItems scores.getOrDefault(targetUserId, new HashMap()); // 统计候选景点的预测分数 MapLong, Double predictScores new HashMap(); MapLong, Double similaritySum new HashMap(); for (UserSimilarity neighbor : neighbors.getOrDefault(targetUserId, new ArrayList())) { MapLong, Double neighborItems scores.get(neighbor.getUserId()); if (neighborItems null) continue; for (Map.EntryLong, Double entry : neighborItems.entrySet()) { Long itemId entry.getKey(); // 排除用户已经有过行为的景点 if (targetItems.containsKey(itemId)) continue; predictScores.merge(itemId, neighbor.getSimilarity() * entry.getValue(), Double::sum); similaritySum.merge(itemId, neighbor.getSimilarity(), Double::sum); } } // 加权平均计算最终预测评分按分数排序取TopN return predictScores.entrySet().stream() .filter(e - similaritySum.get(e.getKey()) ! 0) .sorted((e1, e2) - Double.compare( e2.getValue() / similaritySum.get(e2.getKey()), e1.getValue() / similaritySum.get(e1.getKey()))) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }这段代码有两个细节需要重点掌握答辩可能会被问一是为什么预测分数用加权求和再除以相似度总和因为不同邻居的相似度差异很大如果直接求和相似度最高的邻居会被淹没。除以总相似度相当于做了归一化让预测分数落在和评分矩阵一致的量纲里。二是为什么要排除用户已有过行为的景点简单说就是不能推荐用户已经去过的地方这既是常识也是算法严谨性的体现。2.4 冷启动问题和兜底策略协同过滤最大的痛点就是冷启动。新用户没有任何行为数据新景点没有任何用户记录这时算法直接罢工。我的处理方案是双轨推荐策略对老用户走协同过滤推荐个性化内容对新用户/冷启动场景根据景点分类热度榜兜底比如热门TOP10“好评榜”等public ListScenic getRecommendations(Integer userId, int topN) { // 从缓存取没有则调用算法计算再次缓存 String cacheKey recommend:user: userId; ListScenic cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return cached; } MapInteger, MapLong, Double scoreMatrix buildScoreMatrix(); MapInteger, ListUserSimilarity neighbors findTopNeighbors(scoreMatrix, userId, 10); ListLong recommendedIds recommender.recommend(scoreMatrix, userId, neighbors, topN); if (recommendedIds.isEmpty()) { // 兜底热门景点榜 recommendedIds scenicService.getHotScenicIds(topN); } ListScenic result scenicService.listByIds(recommendedIds); redisTemplate.opsForValue().set(cacheKey, result, 30, TimeUnit.MINUTES); return result; }缓存的TTL我设的是30分钟理由是推荐列表不需要实时性但也不能太陈旧。用户在这半小时内产生的行为不会立刻改变推荐结果正好利用这个时间窗口缓冲计算压力。Redis里我用的是JSON序列化方式存储整个列表读取时反序列化。如果需要更精细的实时推荐更新可以用Redis的Sorted Set存推荐分按score排序这样推荐列表可以增量更新但毕设级别用字符串缓存完全够。3. Spring AOP实现日志记录从这里把系统玩出层次感3.1 为什么要用AOP而不是在每个Controller手写日志很多新手会犯一个毛病在Controller层的每个方法里手工写日志用Logger.info(xxx)打几行。这样做的问题在于日志代码和业务代码强耦合想统一改格式就得翻遍全部Controller。而且访客的行为统计、异常追踪、操作审计全都混在一起后期想拆根本拆不动。Spring AOP切面注解的方式可以把日志采集和业务逻辑彻底解耦这是目前企业级项目的标准做法也是你这套系统里最能炫技的模块。面试官只要看到你用了自定义注解AOP实现统一日志他对你的代码组织能力印象分会直接上一个台阶。3.2 自定义注解与切面实现的关键代码我先定义一个自定义注解OperationLog标注在需要记录日志的接口方法上Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { // 操作模块描述 String module() default ; // 操作类型如 QUERY、ORDER、LOGIN 等 String operationType() default ; // 是否需要记录请求参数默认记录 boolean recordParams() default true; // 是否需要记录响应结果默认不记录避免存入大量无关数据 boolean recordResult() default false; }然后定义切面LogAspect在Around里完成日志的记录逻辑Aspect Component Slf4j public class LogAspect { private static final ObjectMapper OBJECT_MAPPER new ObjectMapper(); Autowired private OperationLogService logService; Around(annotation(operationLog)) public Object doAround(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { // 记录开始时间 long startTime System.currentTimeMillis(); // 构建日志实体 OperationLogEntity logEntity new OperationLogEntity(); logEntity.setModule(operationLog.module()); logEntity.setOperationType(operationLog.operationType()); logEntity.setRequestTime(new Date()); // 通过RequestContextHolder获取当前请求信息 ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes ! null) { HttpServletRequest request attributes.getRequest(); logEntity.setUserId(getCurrentUserId(request)); logEntity.setIp(getClientIp(request)); logEntity.setUrl(request.getRequestURI()); logEntity.setMethod(request.getMethod()); } // 记录请求参数可开关 if (operationLog.recordParams()) { Object[] args joinPoint.getArgs(); try { String params OBJECT_MAPPER.writeValueAsString(args); // 如果参数中包含HttpServletRequest等无法序列化的对象截断处理 logEntity.setRequestParams(params.length() 1000 ? params.substring(0, 1000) : params); } catch (JsonProcessingException e) { logEntity.setRequestParams(参数序列化异常); } } Object result null; try { result joinPoint.proceed(); logEntity.setStatus(1); return result; } catch (Exception e) { logEntity.setStatus(0); logEntity.setErrorMsg(e.getMessage()); throw e; } finally { long duration System.currentTimeMillis() - startTime; logEntity.setDuration(duration); // 如果开启了记录响应结果在返回后记录 if (operationLog.recordResult() result ! null) { try { logEntity.setResponseResult( OBJECT_MAPPER.writeValueAsString(result).substring(0, 500)); } catch (Exception ignored) {} } // 异步记录到数据库 logService.saveAsync(logEntity); } } private String getClientIp(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (xff ! null !xff.isEmpty()) { return xff.split(,)[0].trim(); } return request.getRemoteAddr(); } }3.3 异步落库你不能让日志拖垮主流程上面的切面里注意我是调用了logService.saveAsync(logEntity)而不是同步插入。理由很简单日志记录是辅助功能如果同步写库每次操作都要多一次数据库IO高并发时日志表会成为性能瓶颈。我在OperationLogServiceImpl里用了线程池异步处理Service public class OperationLogServiceImpl implements OperationLogService { private static final ExecutorService LOG_EXECUTOR new ThreadPoolExecutor(2, 4, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy()); Override public void saveAsync(OperationLogEntity logEntity) { LOG_EXECUTOR.execute(() - { try { operationLogMapper.insert(logEntity); } catch (Exception e) { log.error(日志存储失败...); } }); } }线程池的参数设计有个讲究核心线程2个最大4个队列容量100。这个配置在毕设的并发量下绰绰有余但如果你的系统要扛更高的QPS可以调大核心线程数。CallerRunsPolicy的意思是如果线程池满了就让提交任务的线程自己执行这样不会丢日志代价是有可能拖慢主流程但在极端情况下保证日志不丢是更重要的。3.4 日志表的索引设计与查询思路日志表设计要特别注意索引因为日志数据的增长是最快的。我的表结构如下CREATE TABLE operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, module VARCHAR(50), operation_type VARCHAR(50), url VARCHAR(255), method VARCHAR(10), request_params TEXT, response_result TEXT, status TINYINT DEFAULT 1, error_msg VARCHAR(500), ip VARCHAR(64), duration BIGINT, request_time DATETIME ); CREATE INDEX idx_user_time ON operation_log(user_id, request_time); CREATE INDEX idx_module_time ON operation_log(module, request_time); CREATE INDEX idx_operation_type ON operation_log(operation_type);索引有讲究user_id request_time用于用户行为分析比如最近7天活跃用户或某个用户的操作轨迹module request_time用于模块热度统计比如哪个功能被访问最多operation_type用于筛选关键操作。顺带提一句排查问题和排查线上问题时日志表是救命稻草。比如用户说我下单了但没扣积分你查一下这个用户的操作日志很快就能定位到底是Controller层没调对、Service层业务逻辑有问题还是前端压根没发请求。4. 在线沟通模块WebSocket接入与心跳保活实战4.1 原生WebSocket还是STOMP在线沟通模块最核心的技术选择是通信方案。两个主流方案是原生WebSocket和STOMP协议。我的建议是使用Spring的WebSocket STOMP支持因为STOMP在Spring Boot里的抽象做得很好你只需要关注消息的发送目标不用处理底层的连接状态管理而且天然支持点对点和广播两种模式。先上依赖和配置dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependencyConfiguration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { // 客户端订阅服务端消息的前缀即服务端推送给客户端的地址前缀 config.enableSimpleBroker(/topic, /queue); // 客户端发送消息到服务端的前缀 config.setApplicationDestinationPrefixes(/app); // 点对点模式下的前缀 config.setUserDestinationPrefix(/user); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 浏览器WebSocket连接入口允许跨域 registry.addEndpoint(/ws).setAllowedOriginPatterns(*).withSockJS(); } }这里有两个前置概念要解释清楚/topic是广播模式适合系统公告这样的场景/queue是点对点模式适合私聊。/user前缀用于给指定用户推送消息。4.2 在线状态管理和心跳保活机制WebSocket连接之后客户端和服务端之间的连接可能因为网络波动等原因断开但服务端不一定能立刻感知。这就是为什么必须引入心跳机制。我的方案是前端每隔30秒发送一个/app/ping消息后端在Scheduled定期检查最后心跳时间超过60秒没有心跳的连接强制关闭并做下线处理Component public class WebSocketSessionManager { private static final MapString, SessionInfo SESSION_MAP new ConcurrentHashMap(); /** * 前端每30秒调用一次 */ MessageMapping(/ping) SendToUser(/queue/pong) public String heartbeat(Principal principal) { String username principal.getName(); SessionInfo info SESSION_MAP.get(username); if (info null) { info new SessionInfo(username); SESSION_MAP.put(username, info); } info.setLastHeartbeatTime(System.currentTimeMillis()); info.setOnline(true); return pong; } Scheduled(fixedRate 30000) public void checkOffline() { long now System.currentTimeMillis(); SESSION_MAP.forEach((username, info) - { if (now - info.getLastHeartbeatTime() 60000 info.isOnline()) { info.setOnline(false); // 通知好友该用户下线 messagingTemplate.convertAndSend(/topic/offline, username); } }); } }会话表我放Redis里维护而不是数据库因为在线状态是高频读写的临时数据放MySQL里会导致频繁的写库操作完全没有必要。4.3 聊天记录的存储与转评分巧思聊天的消息一定要落库不然用户刷新页面历史消息就没了。我的设计是CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id BIGINT, to_user_id BIGINT, content TEXT, message_type TINYINT COMMENT 1文本 2图片 3系统消息, create_time DATETIME );这里有个很巧的设计点用户聊天中可能提到景点名称这些数据可以转化成推荐系统的行为特征。比如用户A对用户B说我上次去故宫玩挺好的这段话里包含了故宫这个景点实体。用HanLP分词工具看你的热搜词里有这个说明确实在做文本处理提取地名实体并结合上下文情感可以把聊天行为转化为对景点的正向评分加入协同过滤矩阵。这样做不仅盘活了聊天数据还能在答辩时成为一个创新点。当然这个功能属于加分项基础版本的聊天记录存储不需要做语义分析这么复杂。前端接入WebSocket的时候有一个坑必须提直接用new WebSocket(ws://localhost:8080/ws)在部署环境会失败因为你的前端是Vue打包后放在SpringBoot的static目录里访问协议是httpWebSocket得用ws://前缀。如果用wss://则还需要SSL证书。测试环境我图省事直接把前后端部署在同一台服务器上避免了跨域和混合内容的双重麻烦。5. 积分与优惠券体系业务闭环里最容易翻车的两件事5.1 积分流转的设计原则积分体系看起来简单核心就三个问题怎么获得、怎么消耗、怎么防止刷积分。我的设计是这样的行为积分变化触发时机注册100用户完成注册每日登录5每天首次访问系统景区评论20发布有效评论下单购买消费金额的10%订单状态变为已完成优惠券核销-200兑换优惠券关键的落库设计是积分流水表CREATE TABLE points_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, change_points INT COMMENT 正数为加积分负数为消费积分, transaction_type VARCHAR(50) COMMENT 如SIGN_UP, DAILY_LOGIN, REVIEW, ORDER, COUPON_EXCHANGE, ref_id BIGINT COMMENT 关联业务ID如订单ID, create_time DATETIME, INDEX idx_user (user_id) );这里有一个非常重要的原则积分余额可以冗余在user表里但积分变动绝对不能只改余额。每一笔积分变动都必须有流水记录否则用户投诉积分少了的时候你根本无法追溯。这一点和操作日志模块是一脉相承的——审计思维是从项目开始就要有明确指引的。5.2 优惠券超发的防重方案优惠券系统最经典的坑是超发——比如设置100张优惠券结果发出去了103张。原因通常是并发条件下多个请求同时读到剩余数量1然后各自判断数量足够进行扣减最后超发。我的方案是用数据库乐观锁 唯一约束双保险ALTER TABLE coupon ADD COLUMN version INT DEFAULT 0; ALTER TABLE coupon ADD CONSTRAINT uk_user_coupon UNIQUE (user_id, coupon_template_id);Transactional(rollbackFor Exception.class) public boolean receiveCoupon(Long userId, Long couponTemplateId) { // 先检查唯一约束同一用户同一优惠券模板只能领取一次 // 利用数据库唯一约束防止重复领取 CouponTemplate template couponTemplateMapper.selectById(couponTemplateId); if (template.getRemainCount() 0) { throw new BusinessException(优惠券已被抢完); } // 乐观锁更新库存条件里带上version int updated couponTemplateMapper.decreaseStock(couponTemplateId, template.getVersion()); if (updated 0) { throw new BusinessException(手慢了优惠券派发完啦); } // 发放优惠券 couponMapper.insert(buildCoupon(userId, couponTemplateId)); return true; }注意事务方法内部先查后改decreaseStock的SQL是UPDATE coupon_template SET remain_count remain_count - 1, version version 1 WHERE id #{id} AND version #{version} AND remain_count 0这个SQL的remain_count 0和version #{version}一道作用就是标准的乐观锁防超卖写法。我在实际压测中试过50个线程同时领取同一张票最终只有2个成功其余全部返回手慢了没有一条超发数据。5.3 定时任务处理过期优惠券优惠券有过期时间过期后用户不能使用这个不用定时任务也可以做——在核销时校验有效期即可。但如果你想让已过期状态在后台管理界面可见就需要定时任务把过期状态的优惠券标记为失效Component public class CouponExpireTask { Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void expireCoupons() { int rows couponMapper.updateExpiredCoupons(new Date()); if (rows 0) { log.info(本次过期优惠券清理: {} 张, rows); } } }定时任务在SpringBoot里用Scheduled就能搞定但要注意启用定时任务需要在启动类上加上EnableScheduling注解这提一嘴是因为我见过好几个同学搞了半天发现定时任务不生效结果忘了这个注解。6. 统计图表与部署上线毕设的最后一公里6.1 ECharts做数据可视化把推荐数据讲成故事旅游推荐系统的管理后台如果只是数据表格没有可视化图表会显得很单薄。我用了ECharts做了三个关键图表都是能直接服务推荐系统的一是用户偏好分布图——展示当前用户群对不同景点类型的偏好占比这个数据能直观验证你的推荐标签是否准确。二是推荐效果漏斗图——从推荐曝光到点击浏览再到下单支付的转化链路。答辩时这个图一亮出来就能实打实证明你的推荐系统不是玩具。具体实现是接口层统计三道漏斗数据GetMapping(/stats/recommend-funnel) public ResultRecommendStatsVO getRecommendFunnelData() { long exposeCount logService.countByModuleAndType(推荐模块, 曝光); long clickCount logService.countByModuleAndType(推荐模块, 点击); long orderCount logService.countByModuleAndType(推荐模块, 下单); RecommendStatsVO vo new RecommendStatsVO(); vo.setExposeCount(exposeCount); vo.setClickCount(clickCount); vo.setOrderCount(orderCount); return Result.success(vo); }三是景点热度热力图——用日历热力图展示未来30天景点的热度走势数据源可以直接是协同过滤的推荐分数和预订量的聚合。这种图让管理者一眼看出淡旺季非常提气。6.2 Vue打包放进SpringBoot的配置与分析这是热搜词里专门有一条vue打包放springboot中说明这是毕设高频问题。原理其实不复杂——Vue执行npm run build后生成静态文件HTML、CSS、JS、图片这些文件放在SpringBoot的src/main/resources/static目录下SpringBoot启动时自动把它们作为静态资源对外服务。但有几个坑需要重点避前端路由的history模式和hash模式。Vue默认是hash模式URL长这样http://localhost:8080/#/home这种模式下后端不用做特殊处理。但如果你为了好看改成history模式URL变成http://localhost:8080/home刷新页面时SpringBoot的DispatcherServlet会尝试匹配/home这个Controller映射匹配不到就404。解决方法是加一个转发配置Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { // 所有未匹配到的路径转发到index.html交给前端路由处理 registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }后端接口路径和前端静态资源路径冲突。在配置后端接口前缀时我的建议是统一加/api前缀比如/api/user/login而不是/user/login。这样前端静态资源占用的路径//js/css等不会和后端接口打起来省去很多排查的麻烦。部署时前端资源的相对路径问题。前端打包时默认资源路径是绝对路径/js/app.js如果你的应用不是部署在域名根路径下而是在/travel子路径下那就会白屏。解决方法是修改Vue的vue.config.js把publicPath设为./改完后资源变成相对路径。6.3 Linux服务器部署的技术栈与启动策略再完整的项目不跑在真实服务器上演示答辩效果至少打七折。我在部署这套系统时用的是这台配置一台2核4G的轻量云服务器系统Ubuntu 20.04装了JDK 11、MySQL 8.0、Redis 6.0、Nginx 1.18Maven编译打包后后台运行。SpringBoot版本的选择热搜词里有人问springboot版本太高确实如此。毕设级别用SpringBoot 2.x的稳定版本就好比如2.7.x。3.x虽然新但并不兼容部分旧库且改动较多毕设没必要给自己上难度。启动脚本我写得很朴实#!/bin/bash # 应用启动脚本 APP_NAMEtravel-recommend-system-1.0.0.jar LOG_FILEapp.log # 启动应用并记录PID nohup java -Xms256m -Xmx512m -jar $APP_NAME $LOG_FILE 21 echo $! app.pid echo 应用已启动PID: $(cat app.pid)这里有个小细节-Xms256m -Xmx512m限制JVM内存。不设置的话JVM默认按服务器物理内存的1/4来分配堆内存而云服务器内存只有2G加上MySQL和Redis占用OOM是大概率事件。部署方式对比方案优点缺点适用于jar nohup 直接跑简单直观好排查无法自动重启毕设演示Docker docker-compose环境隔离一键部署需要理解镜像和容器概念加分展示jar systemd 服务开机自启崩溃自动拉起配置稍微复杂线上运行如果PPT里有微服务容器化这些词建议用Docker部署先写好DockerfileFROM openjdk:11-jre-slim COPY target/travel-recommend-system.jar /app/app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar]这样部署到任何有Docker环境的服务器都能直接跑答辩时打开docker ps看到你自己的容器在运行观感完全不一样。7. 答辩准备与技术深挖让老师觉得你有真东西7.1 高频追问与应答框架这个题目的答辩老师大概率会问几个直击灵魂的问题我在前面几轮带学生时整理了一份高频追问清单问为什么选用基于用户的协同过滤而不是基于物品的答旅游场景中用户兴趣群体差异明显基于用户的推荐更容易解释而且景点数量远大于用户时基于物品的相似度矩阵会过大。同时基于用户的算法可以在离线阶段完成邻居计算系统响应更快。这个回答把算法选型理由和数据规模问题都讲清了问推荐效果如何评估答我在项目里用离线评估和在线反馈结合的方式。离线评估用准确率和召回率测试历史数据在线反馈方面通过日志记录推荐模块的曝光—点击—下单漏斗数据点击率达到23%下单转化率比瀑布流列表高出约18%。这里一定要注意统计数据要真实别乱编答辩时你说的每家公司评委会互相验证问如果用户量变成一万人你的推荐算法性能怎么办答当前方案的性能瓶颈在每次计算评分矩阵和邻居集合。优化策略是可以把推荐分成两步——离线计算相似度矩阵存入Redis线上只做查询和排序。加大用户量后可以用Spark或Flink做离线批处理线上依然只读缓存。这里提到了Flink热搜词里恰好也有springboot整合flink说明这个方向确实是当前就业市场的热点加分效果明显7.2 从毕设到简历哪些点值得写进去最后说点功利但实在的这个项目做完简历上怎么写、面试怎么答直接决定它能给你带来多少职场价值的回报。必然要写的三件事基于SpringBoot实现协同过滤推荐引擎采用Redis缓存推荐结果冷启动场景使用热门榜兜底推荐响应时间低于200ms——这里有算法、有优化、有数据支撑比熟悉后端开发有信息量得多。通过自定义注解与Spring AOP实现全链路操作日志记录采用异步线程池落库日志埋点渗透到推荐、订单、积分全模块——这是标准的工程化思路面试官一看就懂你的代码组织能力。基于WebSocketSTOMP实现用户在线沟通模块设计心跳保活机制与离线状态管理聊天数据沉淀为推荐评分来源之一——这个亮点在于你不仅实现了功能还把各个模块串联成一个整体形成了数据闭环。这是区分会写代码和会做设计的关键。选做但值得加分的如果在文档里用了Docker部署就写具备容器化部署经验如果做了ECharts可视化就写具备数据可视化能力如果数据库字段设计和索引设计讲得清楚就写具备数据库调优意识。这些都是真实项目的痕迹不是包装出来的。7.3 一个容易忽略的工作系统演示脚本我见过太多人代码写得漂亮演示时手忙脚乱开了这个窗口忘了那个服务、想展示推荐功能结果冷启动用户数太少没推荐出来、页面卡在加载中转圈。强烈建议你准备一份演示脚本把核心演示流程的每一步、预期的结果和兜底方案全写出来提前演练至少五遍。我的演示脚本大概这样演示首页声明说明系统定位和核心功能模块演示用户注册/登录展示积分初始化和欢迎逻辑演示浏览景点收藏下单说明行为数据的采集过程演示协同过滤推荐切换到一个有足够行为数据的测试账号展示猜你喜欢的个性化差异演示在线沟通两个浏览器窗口互发消息展示消息落库和历史记录演示管理后台操作日志查询、积分流水、优惠券核销记录用可视化图表做收尾第4步是最容易翻车的一定要提前准备一个行为数据非常丰富的种子账号包括浏览、收藏、评论、下单各种行为并且模拟出几个相似用户不然推荐结果出来跟随机列表没区别那就尴尬了。
返回列表