资讯中心

从零搭建购物推荐系统:SpringBoot+Vue前后端分离实战

📅 2026/11/6 19:46:44
从零搭建购物推荐系统:SpringBoot+Vue前后端分离实战
做了大半年购物推荐系统从最初只想搭个毕业设计级别的完整前后端项目到最后把协同过滤推荐、行为埋点、MySQL索引优化、Tomcat/Nginx双路径部署全部跑通踩了不少坑也沉淀了不少经验。这套系统我用的是SpringBoot Vue MyBatis MySQL前后端完全分离源码和部署链路都是一步步调通的。如果你正需要一套购物推荐类的实战项目作为毕设、简历项目或者只是想搞懂前后端分离项目的完整落地方式这篇文章应该能省你很多查资料的力气。我会把这套系统的整体设计思路、后端推荐引擎的实现逻辑、前端数据流转方式、MySQL表结构和索引设计以及前后端联调和部署过程中容易踩的坑全部拆开讲重点解释我为什么做这些设计决策而不只是贴代码。1. 购物推荐系统的整体设计思路从「盲目逛店」到「按需推送」1.1 推荐系统要解决的真实痛点传统电商网站的商品列表基本是按销量、价格、上架时间排序用户进站后要么自己搜索要么一页一页翻。这种模式最大的问题在于用户的行为是不带明确意图的但很多流量恰恰来自随便看看的用户。推荐系统的核心价值就是把这部分无明确搜索意图的流量转化为有效曝光让用户在不输入关键词的前提下也能快速发现感兴趣的商品。我在设计这套系统时没有一上来就追求复杂算法而是先明确了一个原则推荐系统再复杂最终都要落到用户-商品-行为这三张基础关系上。所以第一步就是把用户对商品的行为浏览、收藏、加购、下单全部记录下来然后通过加权评分算出用户对不同品类、不同标签商品的偏好再基于偏好去做候选商品排序。这套思路对应到代码里本质上就是一张行为流水表加一个定时或实时触发的推荐计算服务。1.2 选型理由为什么是SpringBootVueMyBatisMySQL这套组合这可能是多数人做前后端分离项目时最纠结的地方。我选这套组合有几个很实际的理由SpringBoot负责把后端服务的装配成本降到最低。如果选传统的SSH或SSM光配置文件就要写一大堆而这个项目的核心精力应该留给业务逻辑——推荐算法、行为埋点、权限控制而不是浪费在Bean装配上。Vue是目前前端生态中上手曲线比较平缓的框架配合Vite脚手架、Element Plus组件库做一个商品展示、购物车、订单管理的SPA页面非常顺手。Vue的路由、状态管理、组件通信机制在这类中后台商城混合场景里表现很稳定。MyBatis作为数据访问层最大的优势在于SQL可控。购物推荐系统里会有大量动态查询条件——多标签匹配、价格区间、排序方式组合这些在MyBatis的XML里写动态SQL非常直观而且方便DBA后期review SQL。相比JPAMyBatis更适合这种对查询逻辑有精细控制诉求的项目。MySQL就是稳妥数据量在百万级以内不需要引入分布式中间件配合InnoDB引擎和合理索引推荐查询的响应时间可以稳定控制在几十毫秒级别。1.3 模块划分与前后端分工系统的功能模块可以拆成两大类基础电商模块和推荐业务模块。基础电商模块包括用户注册登录、商品分类浏览、商品详情、购物车、订单管理。推荐业务模块包括用户行为采集、用户偏好画像计算、推荐位生成、管理员端的推荐规则配置。前端负责页面交互、路由控制、状态存储和后端API的数据展示后端负责业务逻辑校验、数据处理、推荐计算和持久化存储。这里我特别想强调一个设计理念推荐系统的数据源头不是商品本身而是用户行为。所以前后端分工时前端的每一个商品曝光、点击事件都要通过埋点接口回传后端后端再做实时或准实时的偏好计算。如果你的项目只是单纯做一个商城管理后台那推荐也就无从谈起。2. 后端核心推荐引擎与MyBatis数据层的落地实践2.1 行为数据采集接口的幂等设计行为埋点是推荐系统的眼睛。浏览、收藏、加购、下单、支付这五类行为都应该被采集。我设计了一张user_behavior表字段包含用户ID、商品ID、行为类型、行为时间、行为来源场景首页推荐位、搜索列表、详情页、会话ID。这里最容易踩的坑是重复上报。用户在前端快速点击多次加入购物车或者网络抖动导致请求重试后端其实只应该算一次有效行为。解决方式很简单在行为表的user_id、product_id、behavior_type上加唯一索引然后写入时用INSERT ... ON DUPLICATE KEY UPDATE这样重复上报不会产生脏数据重试也不会对推荐画像造成污染。-- 行为表唯一约束 ALTER TABLE user_behavior ADD UNIQUE KEY uk_behavior (user_id, product_id, behavior_type, session_id); -- 重复点击时只更新时间不新增记录 INSERT INTO user_behavior (user_id, product_id, behavior_type, behavior_time, scene, session_id) VALUES (#{userId}, #{productId}, #{behaviorType}, NOW(), #{scene}, #{sessionId}) ON DUPLICATE KEY UPDATE behavior_time VALUES(behavior_time), scene VALUES(scene);2.2 评分模型与推荐算法的实现逻辑行为采集到位后推荐计算就变成了一个算分排序问题。我给行为设计了加权评分规则按用户对商品意图从浅到深排列浏览1分、收藏2分、加购3分、下单4分、支付5分。为什么要分权重因为如果只统计次数用户不小心点开10个商品的浏览行为和主动购买行为没有区分度推荐结果容易被误触流量带偏。推荐算法的核心实现分三步提取用户偏好标签根据用户近30天的行为记录汇总行为分数最高的N个商品标签得到一个用户-标签偏好向量。计算候选商品集从所有商品中筛选出用户未购买过的商品按标签匹配度打分。匹配度计算可以用简单的余弦相似度也可以用加权交集。排序与截断将匹配度得分结合商品自身热度浏览量、销量做加权排序取TopN作为推荐结果。这套逻辑在SpringBoot的Service层实现大概是这样的思路public ListProductVO recommendForUser(Long userId, int limit) { // 1. 拉取用户近30天行为加权后的标签偏好 ListTagWeight userTags behaviorMapper.selectUserTagWeights(userId, 30); // 2. 排除用户已购买商品 ListLong purchasedIds orderMapper.selectPurchasedProductIds(userId); // 3. 基于标签匹配计算候选商品得分核心SQL在MyBatis XML中实现 ListProductScore scores productMapper.scoreByTags(userTags, purchasedIds); // 4. 热度加权后截断返回 return scores.stream() .sorted((a, b) - compareWithHeat(a, b)) .limit(limit) .map(productConverter::toVO) .collect(Collectors.toList()); }2.3 MyBatis动态SQL与批量数据操作这套系统的数据访问层用MyBatis最有价值的地方在于动态SQL。推荐候选集查询是典型的动态条件场景标签可能有多个价格区间可能为空排除商品列表可能很大排序规则可能变化。如果在Java代码里拼SQL字符串既容易出错又难以维护。我在Mapper XML里写了类似这样的动态SQL片段select idscoreByTags resultTypeProductScore SELECT p.id, p.title, p.price, SUM(CASE WHEN p.tags LIKE CONCAT(%, #{tag}, %) THEN #{weight} ELSE 0 END) AS match_score FROM product p WHERE p.status 1 if testpurchasedIds ! null and purchasedIds.size() 0 AND p.id NOT IN foreach collectionpurchasedIds itempid open( separator, close) #{pid} /foreach /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if GROUP BY p.id ORDER BY match_score DESC /select另外一个重点是批量插入用户行为数据。推荐初始化阶段需要灌入大量模拟行为数据如果一条条insert效率非常低。MyBatis的ExecutorType.BATCH配合foreach可以做到几千条数据秒级写入。2.4 缓存和SQL日志等细节配置推荐计算虽然不要求毫秒级实时但也不能每次都全表扫描。我的做法是一级缓存直接用MyBatis默认的同一个SqlSession内重复查询直接命中减少数据库压力。二级缓存不建议全局开启。推荐数据本身随用户行为实时变化如果开启二级缓存同一商品标签的分值可能会出现脏读还要花力气处理缓存刷新策略。性价比不高。热词里专门搜过mybatis二级缓存实现的读者要注意这一点二级缓存适合配置低频变更字典表不适合推荐得分这类高频更新数据。SQL日志配置一定要开。开发环境在application.yml里配mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl生产环境关掉否则日志量会非常大。这个开关在联调阶段救了我很多次SQL执行顺序、参数占位符是否正确一目了然。3. 前端搭建Vue3商品推荐页的交互与数据流3.1 前端脚手架与开发环境配置前端部分我选了Vue3ViteElement Plus没有用Vue CLI因为Vite的冷启动速度和热更新体验在开发阶段差异非常大。环境准备要注意的是Node.js版本Vite 5要求Node.js 18以上如果本地版本太旧建议直接用nvm管理Node版本。安装依赖方面国内网络环境下建议先配置npm镜像源不然npm install会卡到怀疑人生npm config set registry https://registry.npmmirror.com项目创建直接一行命令npm create vitelatest shop-frontend -- --template vue创建后安装路由、状态管理、HTTP库和UI组件库npm install vue-router4 pinia axios element-plus3.2 推荐位、商品列表与路由参数管理前端页面按业务拆分成首页推荐、商品列表、商品详情、购物车、订单列表、后台管理几个路由模块。首页推荐位和商品列表是两个独立页面但它们共用同一套商品卡片组件区别只在数据来源推荐页走后端的/api/recommend/{userId}接口商品列表走/api/product/list接口。路由传参这里有一个容易忽略的点。商品列表的查询条件关键词、分类、价格区间、排序方式应该放在URL的query参数里而不是塞进Vuex或Pinia。原因有两个一是刷新页面后筛选条件不会丢失二是分享链接时对方看到的列表状态和你一致。我在商品列表页用了useRoute和useRouter来同步query参数// 监听路由query变化重新加载商品列表 watch(() route.query, (newQuery) { loadProducts(newQuery); }, { immediate: true }); // 切分类时更新路由query而不是直接调接口 const changeCategory (categoryId) { router.replace({ query: { ...route.query, categoryId } }); };推荐页的商品卡片上有猜你喜欢的推荐理由展示比如因为你看过XX商品所以推荐给你这样展示用户对推荐结果的信任度会高很多。推荐理由的数据是后端在推荐结果里顺带返回的匹配标签前端只负责渲染。3.3 Axios拦截器与统一响应结构前后端分离项目里前端和后端最容易打架的地方是接口规范不统一。我在项目刚启动时就定了一个统一响应结构{ code: 200, message: success, data: {} }Axios封装时请求拦截器统一携带token响应拦截器统一解包data并对错误码做全局提示。这里分享一个细节token不要存到localStorage里应该存到内存变量结合Pinia一起管理刷新页面时通过/api/auth/refresh接口重新获取。localStorage的XSS风险比大多数人想象得高尤其商城类页面上会有大量第三方脚本和广告位注入的场景。// Axios响应拦截器统一处理业务错误码 service.interceptors.response.use( (response) { const res response.data; if (res.code 401) { router.push(/login); return Promise.reject(new Error(未登录或登录已过期)); } return res.data; }, (error) { const message error.response?.data?.message || 网络异常请稍后重试; ElMessage.error(message); return Promise.reject(error); } );4. MySQL表结构与索引设计推荐系统的数据基石4.1 六大核心表的设计与关系购物推荐系统的数据模型围绕用户、商品、行为、订单四要素展开。我整理了六张核心表表名用途关键字段说明user用户表id, username, password, avatar, created_at密码只存BCrypt密文category分类表id, name, parent_id, level支持二级分类product商品表id, title, subtitle, price, stock, tags, cover_url, statustags存逗号分隔标签这是推荐匹配的核心字段user_behavior行为表id, user_id, product_id, behavior_type, scene, session_id, create_timebehavior_type用tinyint存枚举值recommend_record推荐记录表id, user_id, product_id, score, reason, create_time记录推荐命中情况用于后期评估推荐效果order订单表id, user_id, total_price, status, create_time和order_item明细表配合这里有一个设计决策值得展开讲商品tags字段到底是冗余存储还是用多对多关联表。如果按范式设计应该拆成product_tag、tag两张表但推荐系统的标签匹配查询会频繁关联这三张表性能代价非常大。我在项目里选择冗余逗号分隔字符串用LIKE CONCAT(%, tag, %)匹配虽然牺牲了一点规范性但在数据量十万级以内查询效率和代码简洁性都好很多。4.2 为推荐查询量身定制的索引推荐系统的SQL多数是按用户查行为、按行为聚合标签、按标签匹配商品。索引如果建不对数据量上来后接口会从几百毫秒恶化到秒级。我的核心索引设计如下user_behavior表(user_id, behavior_type, create_time)联合索引。这是推荐计算最核心的查询路径——先按用户定位行为记录再按行为类型区分权重最后按时间窗口过滤近30天数据。product表(status, updated_at)联合索引。商品列表页和推荐候选集都要先过滤下架商品再按更新时间排序。product表的tags字段没建索引。LIKE %tag%这种前缀模糊匹配用不上B树索引建了也白建。这算是一个经验教训网上很多人说查询慢就加索引但前缀模糊查询的场景索引确实无能为力。4.3 造数脚本与测试数据准备推荐算法没有数据就是空谈。我写了一个Python脚本生成模拟数据随机生成200个用户、2000个商品、从8个标签池手机数码、家用电器、服饰鞋包、美妆护肤、运动户外、食品生鲜、图书文娱、家居家装中为每个商品随机打标签然后模拟用户行为——让每个用户有60%概率浏览首页推荐位商品30%概率进入详情页15%概率加购5%概率下单。这样灌入数据后推荐接口才真正有东西可以返回推荐效果也能直观评估。生成数据的SQL脚本我单独放在data/simulate_data.py里每次重新初始化数据库后一键执行即可。5. 前后端联调与部署从本机到服务器的完整链路5.1 跨域问题的本质与解决方案前后端分离项目第一个拦路虎就是跨域。跨域的本质是浏览器的同源策略——只要前端页面和后端接口的协议、域名、端口任何一个不一致浏览器就会拦截响应。开发环境下最简单的方案是用Vite的proxy代理。在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }它的原理是让Vite开发服务器作为中间人前端请求/api前缀的接口时Vite帮你去请求http://localhost:8080浏览器层面看到的始终是同源的请求所以不会触发跨域限制。开发环境不要在SpringBoot里配CrossOrigin或全局CORS配置否则和Vite proxy会重复冲突而且把CORS暴露给开发环境容易埋下安全隐患。生产环境部署后就没有Vite了这时候要交给Nginx处理。见下文5.3。5.2 Tomcat部署War包的完整路径如果你需要把项目部署到独立的Tomcat比如学校实验室或生产环境已有Tomcat后端需要做两处调整pom.xml把打包方式从jar改成warpackagingwar/packaging同时排除SpringBoot内置Tomcat避免和外部Tomcat冲突dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency启动类继承SpringBootServletInitializer并重写configure方法SpringBootApplication public class ShopApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(ShopApplication.class); } }打war包时需要注意Tomcat版本和JDK版本匹配。Tomcat 9对应JDK 8Tomcat 10对应JDK 11且包名从javax.servlet改成jakarta.servlet如果版本不匹配部署后会出现ClassNotFoundException这个排错过程比较折磨人。5.3 Nginx静态资源与反向代理配置生产环境我更推荐nginx托管前端dist目录 反向代理后端接口的方式理由有两点一是静态资源交给nginx处理远比Java容器高效二是前端和后端可以同时监听80端口不需要额外开端口防火墙策略更简洁。前端构建npm run build构建产物dist目录上传到服务器后Nginx配置如下server { listen 80; server_name your-domain.com; # 前端静态资源 root /opt/shop/dist; index index.html; # 解决前端路由刷新404问题 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有个细节很多人会忽略try_files $uri $uri/ /index.html;这行配置必须加。不加的话用户在商品详情页/product/123刷新浏览器Nginx找不到对应的物理文件会直接返回404加了这行配置后所有前端路由请求全部回退到index.html由Vue Router接管前端路由解析。这个问题我见过太多人问为什么前端部署后刷新就白屏排查下来基本都是这行配置缺失。后端jar包用nohup启动并做简单的开机自启或systemd服务管理nohup java -jar /opt/shop/shop-backend.jar \ --spring.profiles.activeprod \ /opt/shop/logs/app.log 21 5.4 部署时MySQL连接问题的排查部署过程中最常见的问题是后端日志报错ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock这个报错在Linux服务器上尤其常见根源是MySQL客户端默认通过socket文件连接但socket文件的实际位置不一定在/tmp/mysql.sock。解决办法是在JDBC连接串里明确指定host和port不要依赖socketspring: datasource: url: jdbc:mysql://localhost:3306/shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpasswordcharacterEncodingutf8这个参数也是必须的否则存中文商品的标题和tags会出现乱码。我早期项目在这里栽过跟头商品表里的手机数码变成了ææºæ°ç 排查半天才发现是连接串少了编码参数。6. 这套系统我踩过的坑和留下来的建议6.1 LocalDateTime序列化问题后端实体类用了LocalDateTime前端收到的是一串数组格式的日期而不是ISO字符串导致前端渲染订单时间直接报错。原因是我没配置Jackson的JavaTimeModule。解决方案有两种一是在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这里注意date-format只对java.util.Date生效对LocalDateTime需要额外配置JavaTimeModule并设置序列化器。所以我最终选择在实体字段上统一加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解这样最直观可控。6.2 N1查询问题推荐列表返回接口时如果循环中逐条查询商品图片或分类名称就会出现典型的N1查询问题。用户请求一次推荐列表本来是1次SQL搞定结果变成1N次。处理方案是查完推荐商品ID集合后用一条WHERE id IN (...)批查出全部商品详情再在Java内存里组装成Map避免循环查询。这个优化做完推荐接口的响应时间从平均320ms降到了45ms效果非常明显。做电商类项目时任何列表详情组合的场景都要警惕N1这也是MyBatis中collection标签和association标签正确使用的关键场景。6.3 前端白屏的排查顺序有段时间前端部署后访问首页一直白屏控制台报错也看不清楚。排查顺序建议是先确认dist目录产物是否正常再确认Nginx的root路径是否指向了正确的dist目录最后看index.html里引用JS的路径是不是绝对路径。Vite默认的资源引用路径是/assets/xxx.js如果你的静态资源部署在Nginx子路径下需要修改base配置。这个问题的排查思路放到更大的场景里也一样成立——前端白屏优先看资源加载路径其次看JS运行时错误最后看接口报错从外到内逐层定位。6.4 关于推荐系统冷启动的一点个人体会这套系统跑通后我最大的体会是推荐算法本身不算难难的是冷启动阶段没有行为数据时的推荐策略。用户刚注册没有行为记录时我的方案是默认按商品热度浏览量加权重推荐并混入20%最新上架商品保证新商品有曝光机会。等用户行为数据积累到一定阈值后再切到个性化推荐。这个策略在真实场景里也有参考价值。很多系统上线初期都面临没有数据推荐引擎是个空架子的尴尬冷启动方案不是事后补丁而应该是推荐系统设计的一部分从一开始就要想清楚。最后再分享一个小技巧推荐记录表recommend_record不只是用来存储推荐产物它还要记录推荐位曝光时用户是否点击了。把这些数据定期做统计分析就能算出推荐位点击率用来反推推荐算法的调整方向。这是整张表最重要的价值——让推荐系统自己能持续进化而不只是一堆静态数据的容器。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案