做购物推荐网站这个题目,在Spring Boot毕设里属于常青树了——既有标准化的CRUD、权限、购物车、订单这套“保底”功能,又能通过推荐引擎把项目的技术含量拉起来,不管是课程设计还是毕业论文答辩,讲出来都比纯增删改查有看点。我之前把整套东西从零搭完,从数据库设计到协同过滤落地,再到后面部署打包踩的坑,都记录在这篇文章里了。不管你是准备拿它做毕业设计、期末项目,还是单纯想学一下“推荐功能到底怎么跟业务系统结合”,这份复盘应该都能帮你省下不少时间。
项目核心信息
- 技术栈:Spring Boot 2.x + MyBatis Plus + MySQL + Redis(缓存可选)+ Maven
- 推荐方案:基于用户的协同过滤(UserCF)+ 基于物品的协同过滤(ItemCF)双通道
- 功能模块:用户端(注册登录、商品浏览搜索、购物车、下单、评分评论)+ 管理端(商品管理、订单管理、用户管理、数据统计)
- 运行环境:JDK 1.8 + Maven 3.6+ + MySQL 5.7/8.0
下面按五个部分来讲,第一部分是整体设计和为什么这么选型,第二部分是数据表怎么建,第三部分是推荐算法怎么落到代码里,第四部分是核心业务功能怎么实现,第五部分是启动、打包和问题排查实录。
1. 项目从0到1:整体设计与技术选型思路
1.1 为什么是Spring Boot而不是SSH或SSM
早几年做这类网站,很多教程还在用SSM(Spring + Spring MVC + MyBatis),配置一堆XML,一个项目光环境搭建就能折腾三五天。Spring Boot最大的价值在于约定大于配置——内嵌Tomcat、自动装配、Starter机制,你把依赖往pom里一扔,一个@SpringBootApplication注解就能启动整个项目。这对做毕设的同学来说极其友好,因为你可以把精力放在业务逻辑和推荐算法上,而不是被spring-mvc.xml、mybatis-config.xml这些配置文件反复折磨。
我在实际开发里选择的组合是Spring Boot 2.7 + MyBatis Plus 3.5。MyBatis Plus很大程度上是“傻瓜式”的,单表CRUD不用写SQL,自带分页插件,代码生成器还能一键生成实体和Mapper。配合Lombok,实体类里连setter/getter都不用写。这套组合下来,代码量比传统SSM大概省掉三分之一。
1.2 购物车+推荐系统,为什么要做两个计算通道
纯粹的购物网站只要把商品、用户、订单三张表管理好就能跑通。但“购物推荐”这个关键词决定了项目不能只做CRUD,推荐功能才是整个项目的技术核心。
我采用的方案是“双通道推荐”:
- 基于用户的协同过滤(UserCF):找到和你兴趣相似的其他用户,把那些用户收藏过、买过、给过高分的商品推荐给你。适合用户量不太大的场景,第一天注册的新用户也能在登录后快速获得推荐结果。
- 基于物品的协同过滤(ItemCF):分析商品之间的关联关系——“看了A商品的用户,还看了B商品”。在商品数量有限的情况下计算速度快,结果也比较稳定,适合做“猜你喜欢”“相关推荐”这类模块。
为什么要两条通道都做?因为单独用其中一个,都会有明显的局限性。UserCF冷启动严重——新商品没人评过分,永远推荐不出去;ItemCF则比较难挖掘用户潜在的新兴趣。两条通道的结果做加权融合,再结合热度排序(点击量、销量、评分人数),推荐效果会好很多。答辩时这也是一个很稳的加分点,因为你证明了你不只是“调用了一个现成算法”,而是考虑了实际业务里的多目标平衡。
1.3 整体架构:单体应用,模块化代码组织
推荐系统在工业界会拆成搜索、推荐、捞数等多个微服务,但做课程设计/毕设用微服务属于过度设计——维护成本高、部署麻烦,而且面试官和答辩老师大概率会问你“为什么这个规模要用微服务”。我选择的是单体应用 + 模块化代码组织:
shopping-recommend/ ├── src/main/java/com/example/mall/ │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑层(含推荐算法) │ ├── mapper/ # MyBatis Plus数据访问层 │ ├── entity/ # 实体类 │ ├── dto/ # 参数接收与视图对象 │ ├── config/ # 全局配置(跨域、拦截器、线程池) │ ├── common/ # 统一返回结果、异常处理 │ ├── recommendation/ # 推荐算法核心包 │ │ ├── core/ # 协同过滤算法 │ │ ├── strategy/ # 推荐策略接口与实现 │ │ └── task/ # 定时任务(离线计算) │ └── ShoppingApplication.java ├── src/main/resources/ │ ├── mapper/ # 自定义SQL │ ├── static/ # 前端静态资源 │ └── application.yml └── pom.xml在recommendation包下独立处理算法,跟业务代码分开,这样做的好处是:你想调整算法策略的时候,不用动Controller和Service的代码。比如今天用的是UserCF,明天想换成基于矩阵分解的ALS算法,只需在strategy包下面新增一个实现类,再改一下策略选择的地方就可以了,其他代码全部不需要动。
2. 数据库设计:用几张表撑起整个推荐系统
2.1 核心表结构一览
数据库是这类项目的底座,表设计得不合理,后面写SQL和排查数据问题都会很难受。我的表结构一共7张表,覆盖了用户、商品、购物车、订单、评分和推荐结果:
| 表名 | 作用 | 关键字段 |
|---|---|---|
user | 用户表 | id、username、password、nickname、avatar、created_time |
category | 商品分类 | id、name、parent_id、sort |
product | 商品表 | id、category_id、name、subtitle、main_image、detail、price、stock、sales |
cart_item | 购物车表 | id、user_id、product_id、quantity、checked |
order | 订单表 | id、order_no、user_id、total_price、status、payment_time、created_time |
order_item | 订单明细表 | id、order_id、product_id、product_name、product_image、current_unit_price、quantity |
rating | 评分表(推荐核心) | id、user_id、product_id、score、comment、created_time、update_time |
recommend_result | 推荐结果表(离线计算) | id、user_id、product_id、score、reason_type、created_time |
订单和订单明细拆成两张表,这是电商系统的常规操作。订单表记录一次交易的汇总信息(总额、状态、时间),订单明细表记录这次交易包含哪些商品、每个商品当时的价格和数量。为什么价格要冗余到明细表里?因为商品的价格会变动,半年后你不能让用户看到的历史订单价格跟着商品表一起变,快照式的冗余在这里是必要的,这不是数据冗余,这是数据追溯。
评分表是整个推荐系统的“燃油”。UserCF算法的输入是用户-商品评分矩阵,矩阵的每一行是用户、每一列是商品、值就是用户给商品的打分。推荐结果的准确性高度依赖评分数据的质量。
2.2 为什么必须要做推荐结果表
我在设计时专门加了recommend_result这张表,很多人不理解:推荐结果不是实时算的吗?为什么要存一张表?
这里要区分“实时推荐”和“离线推荐”两种模式。真实电商系统里的推荐服务是实时计算的,用户一刷新页面,后台就根据他最新的行为数据重新计算推荐列表。但你做的是课程设计,如果每次请求都实时跑协同过滤,会有三个问题:
- 性能扛不住。协同过滤涉及大量相似度计算,用户量到几千、商品到几百的时候,UGC矩阵就会很大,一个页面请求可能要等好几秒才算完。
- 用户体验差。点开商品详情页,页面要转5秒钟才出推荐,这体验没法看。
- 答辩风险高。老师问“你这个推荐为什么这么慢?”你很难解释。
所以我采用了典型的时间换空间策略——用定时任务在凌晨算好结果,存到recommend_result表里,白天用户请求时直接查表返回。这样接口响应时间在50毫秒以内,用户体验完全不受影响。定时任务的实现用Spring自带的@Scheduled注解,一行配置就能跑起来,不需要额外引入Quartz。
2.3 建表SQL中的几个关键细节
这个项目里我踩过一个具体的坑:cart_item表如果不对(user_id, product_id)加唯一索引,用户连续点击两次“加入购物车”,就会生成两条一模一样的记录。正确的做法是创建唯一索引uk_user_product,然后插入时用ON DUPLICATE KEY UPDATE,让重复点击变成数量累加。
订单号字段建议用varchar(32),不要用bigint或者int。因为订单号通常由时间戳+随机数/用户ID拼接而成,如果设计成数值类型,要么长度溢出,要么在高并发场景下会重复。我用的生成规则是DateTimeUtil.getCurrentTime("yyyyMMddHHmmss") + 用户ID + 随机4位数字,比如2024102415301200121234。
价格字段一定要用decimal(10,2),千万别用float或double。这一步经常被忽略,等到算总价时出现0.30000000000000004这种浮点误差,再回头改表就迟了。购物车勾选多个商品计算总价时,BigDecimal相比浮点数也更能保证精度。
3. 推荐算法落地:从公式到可以跑的Java代码
3.1 用户-商品评分矩阵的构建
这部分的实现是整个项目含金量最高、也最值得在文档里详细展开的地方。协同过滤讲原理很容易,但落到具体代码时,第一个问题就是:数据从哪里来?
在真实系统里,用户的行为日志是推荐算法的数据源。但课程设计没有埋点系统,我做的是简化处理——把三类行为都折算成“分数”写入rating表:
| 用户行为 | 折算分数 |
|---|---|
| 用户主动打1-5星 | 实际分值(1-5) |
| 用户下单购买商品 | 5分 |
| 收藏或加入购物车 | 3分 |
为什么下单是5分而不是更高?因为评分表的分值区间设置的是1-5。插入时统一按这个规则写入,比如用户把某商品加入了购物车,就在rating表里插入一条score=3的记录;之后真的下单了,就把这条记录的score更新为5。
这样构建出来的评分矩阵A,行是用户,列是商品,A[u][i]表示用户u对商品i的评分,没有评过分的置为0。用Java存储这个矩阵,最直接的做法是Map<Long, Map<Long, Double>>,外层key是用户ID,内层key是商品ID,value是分数。数据量不大(几千用户×几百商品)时这个结构够用了。如果你系统里数据量特别大,再考虑用二维数组配合稀疏存储,不过毕设规模基本不需要,不用自己给自己加复杂逻辑。
3.2 用户相似度计算:余弦相似度的Java实现
UserCF的核心是计算用户之间的相似度。常用的相似度指标有皮尔逊相关系数、余弦相似度、Jaccard相似度。我选的是余弦相似度,因为它实现简单,而且在评分数据相对稀疏的时候效果还可以。公式就不贴了,用大白话讲就是:把两个用户对共同评分过的商品的分数看作两个向量,余弦相似度的值越接近1,说明两个向量的方向越一致,也就是说这两个用户的口味越相似。
核心代码长这样:
public class UserCF { private Map<Long, Map<Long, Double>> userItemMap; /** * 计算两个用户的余弦相似度 */ public double calcUserSimilarity(Long userIdA, Long userIdB) { Map<Long, Double> itemsA = userItemMap.getOrDefault(userIdA, new HashMap<>()); Map<Long, Double> itemsB = userItemMap.getOrDefault(userIdB, new HashMap<>()); // 找到共同评分的商品 Set<Long> commonItems = new HashSet<>(itemsA.keySet()); commonItems.retainAll(itemsB.keySet()); if (commonItems.isEmpty()) { return 0.0; } double dotProduct = 0.0; double normA = 0.0; double normB = 0.0; for (Long itemId : commonItems) { dotProduct += itemsA.get(itemId) * itemsB.get(itemId); } for (double score : itemsA.values()) { normA += score * score; } for (double score : itemsB.values()) { normB += score * score; } if (normA == 0.0 || normB == 0.0) { return 0.0; } return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); } }这段代码看起来简单,但有几个细节值得注意:
commonItems.retainAll(itemsB.keySet())这一步是性能关键。如果两个用户没有任何共同评分商品,那这两个人之间就缺乏比较的锚点,相似度直接返回0,没必要继续算后面的向量内积和模长。- 分母里的
sqrt(normA) * sqrt(normB)是对向量做归一化,这一步很关键。如果不除以模长,那么评分行为多的用户天然跟谁相似度都高,推荐结果会被“刷分用户”带偏。 - 实际项目里你不要对全量用户两两都算一遍相似度,那是O(n^2)的复杂度,用户量一上千就扛不住了。优化的思路是先筛选候选集——只考虑跟当前用户有过共同评分商品的用户,再在候选集内计算相似度,计算量会小一个数量级。
3.3 推荐Top-N商品的完整流程
有了用户相似度之后,推荐流程就是一个标准的三步走:
- 找K个最近邻用户(相似度最高的K个用户)
- 把这K个用户评分过的商品集合合并起来
- 排除当前用户已经买过/评过分/加入过购物车的商品,剩下的按加权分数排序,取前N个
加权分数的公式是:对于候选商品i,pred(u, i) = Σ(sim(u, v) * r(v, i)) / Σ|sim(u, v)|,分子是近邻用户的相似度乘以该用户对商品i的评分,分母是对相似度绝对值求和做归一化。
代码实现如下:
public List<Long> recommendItems(Long userId, int k, int topN) { // 1. 计算当前用户与其他所有用户的相似度,取TopK List<Map.Entry<Long, Double>> similarityList = new ArrayList<>(); for (Long otherUserId : userItemMap.keySet()) { if (otherUserId.equals(userId)) continue; double sim = calcUserSimilarity(userId, otherUserId); if (sim > 0.01) { // 过滤掉相似度过低的 similarityList.add(new AbstractMap.SimpleEntry<>(otherUserId, sim)); } } similarityList.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); List<Map.Entry<Long, Double>> topK = similarityList.subList(0, Math.min(k, similarityList.size())); // 2. 加权打分 Map<Long, Double> scoreMap = new HashMap<>(); Map<Long, Double> weightSumMap = new HashMap<>(); Map<Long, Double> myItems = userItemMap.getOrDefault(userId, new HashMap<>()); for (Map.Entry<Long, Double> entry : topK) { Long neighborId = entry.getKey(); double sim = entry.getValue(); Map<Long, Double> neighborItems = userItemMap.getOrDefault(neighborId, new HashMap<>()); for (Map.Entry<Long, Double> itemEntry : neighborItems.entrySet()) { Long itemId = itemEntry.getKey(); double score = itemEntry.getValue(); // 排除自己评过分的商品 if (myItems.containsKey(itemId)) continue; scoreMap.put(itemId, scoreMap.getOrDefault(itemId, 0.0) + sim * score); weightSumMap.put(itemId, weightSumMap.getOrDefault(itemId, 0.0) + Math.abs(sim)); } } // 3. 归一化排序取TopN List<Map.Entry<Long, Double>> result = new ArrayList<>(); for (Map.Entry<Long, Double> entry : scoreMap.entrySet()) { double normScore = entry.getValue() / weightSumMap.getOrDefault(entry.getKey(), 1.0); result.add(new AbstractMap.SimpleEntry<>(entry.getKey(), normScore)); } result.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); List<Long> recommendIds = new ArrayList<>(); for (int i = 0; i < Math.min(topN, result.size()); i++) { recommendIds.add(result.get(i).getKey()); } return recommendIds; }基于物品的协同过滤(ItemCF)代码结构和这个很像,只是把“用户相似度”换成“商品相似度”。商品相似度的计算逻辑是:两个商品被同一批用户评过分,就认为它们相关。最终推荐时,拿用户最近评过分或买过的商品,去找跟这些商品最相似的Top-K个商品,过滤掉用户已有的,剩下的就是ItemCF的推荐结果。
从代码实践的角度,两个CF算法建议统一一个接口RecommendStrategy,分别提供实现类,后面如果要替换算法策略,整体会更方便。
3.4 冷启动与热门兜底策略
协同过滤有一个天然缺陷:冷启动。新用户没有行为数据,算不出相似用户;新商品没人评分,永远不会被推荐出去。为了不让这个缺陷成为项目短板,我做了一个“三级回流”的兜底策略:
- 一级:有用户行为数据时,走UserCF+ItemCF双通道加权排序;
- 二级:用户行为数据很少(评分记录少于5条)时,降级为“猜你喜欢”热门榜单——按照商品的销量、评分人数、评分均值做加权排序;
- 三级:完全没登录时,直接展示热门商品 + 最新上架商品。
这个策略用代码实现就是推荐服务里的一个分支判断:
public List<ProductVO> getRecommendList(Long userId, int topN) { if (userId == null) { // 未登录:热门兜底 return hotProductService.getHotProducts(topN); } int ratingCount = ratingMapper.countByUserId(userId); if (ratingCount < 5) { // 行为数据太少:热门榜单过渡 return hotProductService.getHotProducts(topN); } // 正常双通道融合推荐 List<Long> userCfIds = userCfStrategy.recommend(userId, topN); List<Long> itemCfIds = itemCfStrategy.recommend(userId, topN); return mergeAndRank(userCfIds, itemCfIds); }这套降级设计的价值,在文档和答辩里体现出来的话,老师会觉得你考虑了真实场景下的算法局限性,而不是只知道套公式。
3.5 用定时任务做离线推荐计算
因为推荐结果算完要存表,我用@Scheduled注解做每天一次的离线任务,凌晨2点执行,把每个用户的推荐结果算好写入recommend_result表:
@Component public class RecommendTask { @Autowired private RecommendService recommendService; @Scheduled(cron = "0 0 2 * * ?") public void dailyRecommendJob() { // 1. 加载全量用户评分数据,构建矩阵 // 2. 遍历每个用户调用推荐算法 // 3. 清洗recommend_result表后批量插入 RecommendContext context = recommendService.buildContext(); List<RecommendResult> results = recommendService.recommendForAllUsers(context); recommendService.refreshRecommendResult(results); } }这里有个性能细节:不要一条条插入recommend_result,几千个用户每人算10条推荐,就是几万条数据,一次插入一条肯定扛不住。我改成了MyBatis Plus的批量插入,实测5万条数据写入时间从几分钟降低到几秒。
另外还有一个小优化点:定时任务跑的时候,商品库存和上下架状态可能已经发生变化,凌晨算的结果到白天可能有过期的商品。所以推荐结果表里的商品映射到前端展示时,要再JOIN商品表校验一次product.status = 1(上架状态),把失效商品实时过滤掉。
4. 核心功能实现:从登录注册到订单闭环
4.1 用户登录与JWT Token机制
用户模块没什么神秘感,但“登录之后才能个性化推荐”是整个推荐链路的前提。我用的认证方案是JWT(JSON Web Token),区别于传统的Session方案——不做服务端会话存储,Token本身就是凭证。实现步骤是:用户登录成功后,服务端生成一个带过期时间的Token串返回给前端;前端每次请求在Header里带上Authorization: Bearer <token>;后端写一个拦截器(HandlerInterceptor)负责校验Token、解析出用户ID、放入ThreadLocal,供后续业务使用。
JWT方案在前后端分离项目里比Session好用很多,因为前端部署在Nginx,后端是独立服务,Session不方便做跨域共享,而JWT天然无状态。密码存储用的是BCrypt加密,不是MD5——MD5虽然快但太容易查表破解,BCryptPasswordEncoder是Spring Security自带的一个密码编码器,每次哈希自动加盐,同一个密码两次加密结果都不一样,安全性完全够用。
4.2 注册登录的核心代码与配置
Spring Boot里集成JWT比想象中简单,核心流程就是三步:登录成功生成Token、写拦截器校验Token、放行白名单。
// 登录接口核心逻辑 public LoginVO login(String username, String password) { User user = userMapper.selectByUsername(username); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { throw new BizException("用户名或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), 7 * 24 * 3600 * 1000L); return new LoginVO(token, user); }拦截器里我特别强调了白名单机制:注册接口、登录接口、商品列表、商品详情这些不需要登录就能访问的接口要放行;购物车、订单、评分、推荐接口必须登录。白名单用数组管理,后期新增一个公开接口,只需要在这个数组里加一行,不用改拦截逻辑。
public class JwtInterceptor implements HandlerInterceptor { private static final String[] WHITE_LIST = { "/api/user/register", "/api/user/login", "/api/product/**", "/api/category/**", "/api/recommend/hot" }; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行白名单 for (String pattern : WHITE_LIST) { if (AntPathMatcher.match(pattern, request.getRequestURI())) { return true; } } // 校验Token String token = request.getHeader("Authorization"); // ... 解析Token,失败则返回未登录状态码 } }4.3 商品模块:列表、搜索、分页
商品列表页能不能快速打开,直接影响第一印象。我用MyBatis Plus自带的分页插件PaginationInnerInterceptor做分页,条件查询用LambdaQueryWrapper构造。搜索功能依靠product表的name字段做模糊匹配,虽然简单,但注意一个坑——直接用LIKE '%xxx%'在大数据量下会全表扫描。作为一个课程设计,商品几千条,这个性能问题不大;但如果你想让项目更有说服力,可以给自己的搜索表增加一个前缀索引,或者引入Elasticsearch做全文检索。不过那属于锦上添花,不是必须项。
商品列表接口的返回数据里,我额外加了一个recommendScore字段,表示这个商品在推荐算法里的分数,用于前端展示“为你推荐”模块时排序。这样前端就不用调两个接口再自己做合并排序了。
4.4 购物车与订单:状态流转与事务控制
购物车的增删改查属于标准CRUD,唯一需要注意的是“加入购物车”接口的原子性——用INSERT ... ON DUPLICATE KEY UPDATE处理重复数据问题,这个在前面表设计时提过的唯一索引就是为了这一步做的铺垫。
订单模块比较关键的是事务控制。用户下单包含多个步骤:创建订单表记录、批量插入订单明细、扣减商品库存、清空购物车对应项。任何一个步骤失败,都不能让其他步骤生效。我用@Transactional(rollbackFor = Exception.class)标注在Service方法上,并配合Redis分布式锁防止超卖(没有Redis环境时可以退化为synchronized+ 数据库乐观锁版本号字段,但正规做法还是用Redis)。
这里推荐在你的源码注释里把事务边界写清楚,后期写文档直接复制注释内容就能扩充成“数据库事务设计”一节,省不少力气。
4.5 评分模块:给推荐算法“喂数据”
评分入口有两个:商品详情页打分,下单完成后跳出的评价弹窗。前端提交的JSON长这样:{"productId": 1, "score": 5, "comment": "很满意"}。后端接收后写入rating表,同时要更新recommend_result——因为刚才那条推荐已经不再新鲜了,等明天凌晨定时任务重算即可。平时用户的行为数据到达一定阈值后当次推荐结果才会更新,这是一种“最终一致性”的思路,够用且不复杂。
5. 运行、打包与常见问题排查实录
5.1 IDEA启动Spring Boot项目不显示端口号怎么排查
这是一个很常见的问题,我之前研究过。正常启动时,控制台会输出“Tomcat started on port(s): 8080”这样的日志。如果启动完了没看到端口号,别慌,首先确认是不是日志级别把启动日志过滤掉了。查看application.yml里是否有类似logging.level.root=WARN的配置,把root级别改成INFO就能看到启动日志了。
其次要排查启动是否真的成功了。打开浏览器访问http://localhost:8080/api/product/list,如果能返回JSON数据,说明服务早就起来了,只是日志显示问题;如果连不上,再排查是不是端口被占用了——Windows上用netstat -ano | findstr 8080命令,查到占用进程直接进任务管理器结束。
5.2 Maven打包跳过测试,打出的jar包却是“没有主清单属性”
这个问题几乎每个搞Spring Boot的人都会遇到。错误信息是no main manifest attribute, in xxx.jar,主要原因就是pom里忘了引入Spring Boot的Maven插件:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>这个插件的主要作用有两个:
- 把项目打成可执行jar包并生成MANIFEST.MF清单文件,里面指定了
Main-Class。 - 打包时把所有依赖的jar包解压并重新组织到
BOOT-INF/lib下,这样直接用java -jar启动时能找到第三方类。
执行mvn clean package -DskipTests,打完包后用java -jar shopping-recommend-0.0.1-SNAPSHOT.jar启动,看到Spring Boot logo和端口号,说明部署这一步通过了。
5.3 项目连接不上数据库的几种情况
这类项目一大半的启动失败都是数据库连接问题。常见的有:
Access denied for user 'root'@'localhost':用户名或密码错误,检查application.yml里的spring.datasource.username/password,看一下是不是被改过。Unknown database 'shopping_mall':数据库没建。先在MySQL里执行CREATE DATABASE shopping_mall DEFAULT CHARACTER SET utf8mb4;,再导入sql脚本。Public Key Retrieval is not allowed:MySQL 8.0的caching_sha2_password认证方式问题。在JDBC连接串上加allowPublicKeyRetrieval=true即可,或者把用户加密规则改成mysql_native_password。
5.4 前端页面跨域错误
前后端分离部署时,前端跑在8081端口,后端在8080端口,调用接口必然遇到跨域。我在config包里写了一个全局跨域配置,继承WebMvcConfigurer重写addCorsMappings方法,允许所有路径、所有来源,允许携带凭证:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }5.5 Spring Boot服务器413错误
最后再分享一个比较隐蔽的问题。如果你在上传商品图片时遇到HTTP 413错误,那是Nginx或Tomcat上传大小的限制。Spring Boot默认上传大小为1MB,在application.yml中调整:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB如果挂了Nginx,还要在Nginx配置中同步修改:
client_max_body_size 10m;两边有一边没改,都会继续报413。这个属于整合型问题,排查时先确认请求到没到Tomcat,到了改Spring;没到改Nginx。
5.6 排查问题的一个核心思路
最后总结一下排查经验:遇到任何问题,先判断问题出在哪个环节,是前端(根本没发请求)、网络(Nginx拦截/超时)、后端(Tomcat报错)、还是数据库(SQL执行失败)。然后看日志,后端问题99%都能从日志中找到根源。控制台日志级别不够就把debug: true开起来,Spring Boot的日志信息量还是很大的,基本能把最后一个报错位置直接定位到哪一行代码。
我个人在实际操作中的体会是:这种“源码+文档”型项目,最大的价值不在那十几张表和一百多个接口,而在于把推荐算法真正跑通了一次。你可以把全部代码写完以后,往rating表里手动插入几十条模拟评分数据,然后调一下推荐接口,看看推荐出来的商品是否符合预期——这一步验证做完,你对协同过滤的理解绝对和只读书不一样。另外建议文档里多放几张核心接口的请求/响应JSON示例、几张关键表的ER图和推荐算法的时序图,这类可视化素材在评阅时非常加分。如果后续有时间,还可以把MyBatis Plus换成Spring Data JPA、把推荐结果接入Redis缓存、或者把离线计算结果改成基于用户实时行为的增量更新,这些都是很好的扩展方向,也是源码之外真正值得沉淀的东西。