基于SpringBoot和Vue.js的积分制零食自选销售平台设计与实现 📅 发布时间:2026/9/16 4:49:23 👁 浏览次数: 最近在做一个基于SpringBoot和Vue.js的积分制零食自选销售平台从IDEA里把项目跑起来的那一刻我突然意识到这类项目看起来只是一个“积分换零食”的小系统但真要做稳、做完整涉及的工程点一点也不少。这个平台的核心场景其实很清晰用户手里有一笔积分不是人民币在零食货架上自助挑选商品下单时用积分抵扣。管理员负责维护零食库存、上下架商品、查看订单和处理发货。对很多刚开始做Java全栈项目的人来说这是典型的SpringBoot Vue.js全栈题目也是面试里能够完整讲清楚业务闭环的项目之一。所以我想把这套系统的设计和实现过程完整记录下来把我踩过的坑、优化思路和最终能跑通的代码结构都梳理一遍。不管你是正在准备毕业设计、课程设计还是单纯想从零做一个可用于简历的Java全栈项目这篇内容应该都能帮你省下不少时间。1. 积分制零食自选销售平台到底在解决什么问题1.1 业务需求拆解这个平台首先是一个“零食商城”但它没有真实的线上支付核心货币是积分。积分从哪来可以由管理员手动发放、签到获得、完成任务获得等等。用户看到的是“价格”变成了“所需积分”结算时扣的是积分余额而不是走微信或支付宝支付。这种积分制的玩法在企业福利、学校活动、机构内部激励场景中非常常见。我把它拆成两个端来看逻辑会清楚很多用户端注册登录、查看零食分类和详情、用积分自选下单、查看个人积分明细和订单状态。管理端登录后台、管理商品分类与零食上下架、设置积分价格、处理用户订单、调整用户积分账户。一句话总结用户能自助选购管理员能管货、管单、管积分整个平台的业务闭环就完整了。对比一下传统的电商系统这个项目省去了支付网关、物流跟踪、售后退款等复杂模块但保留了商品、购物车、订单、库存、账户流水这些核心领域模型。所以它是一个很适合练手的“电商骨架”把业务核心吃透了以后扩展什么的都不难。1.2 核心角色与流程系统的角色其实不太复杂我看很多初学者的项目会把角色做成3个甚至4个结果权限控制糊成一团。这个场景里最核心的就两类USER普通用户和 ADMIN管理员。主流程可以这么理解用户注册账号系统自动为他创建一个积分账户初始积分可以是0也可以由管理员在后台配置赠送。用户浏览零食商品把心仪的零食加入“购物车”或直接兑换。用户提交订单后端校验积分余额是否充足如果足够就扣减积分并生成订单。管理员在后台看到新订单完成出库或发货操作。用户再次进入平台时可以看到自己的积分流水和订单状态。注意第3步是整个系统的命门如果积分扣减没有并发保护同一个用户同时提交两笔订单很容易把积分扣成负数。后面我会专门讲这块这也是项目里技术含量最高的位置。1.3 为什么选SpringBoot Vue.js这套组合技术选型上SpringBoot Vue.js算是当前Java全栈项目里最稳妥的组合之一。SpringBoot解决了传统SSH项目配置繁琐的问题内置Tomcat依赖也通过Spring Boot Starter统一管理非常适合快速迭代。Vue.js则承担了前端页面交互。积分自选销售平台有很多“选中、加入、取消”这类即时交互动作用Vue的响应式数据来做非常顺手。再配一个Element UI或Element Plus组件库后台页面、商品卡片、弹窗提醒都能很快搭出来。开发工具方面我在IntelliJ IDEA里同时写后端和前端。IDEA的Spring Initializr可以直接创建SpringBoot项目内置的Database工具还能直接连MySQL看表数据Debug打断点也比Eclipse顺手很多。这个组合不是唯一解但绝对是投入产出比最高的方案。2. 数据库表结构把积分账户和流水账本先设计明白2.1 不要只设计一张用户表和一张商品表很多第一次做这类平台的人数据库表就画了用户表、商品表、订单表然后把积分直接做成用户表里的一个字段。这样最直接但也是最容易出问题的设计。我建议至少要有这几张核心表表名作用sys_user用户账号表存用户名、密码、昵称、角色points_account积分账户表每个用户一行存当前积分余额points_log积分流水表所有积分变动都有记录category零食分类表snack零食商品表存名称、图片、所需积分、库存、上下架状态orders订单主表存订单号、用户id、总积分、状态order_item订单明细表存订单里每个零食的id、数量、单个积分快照为什么要单独拆一张points_account因为用户表和积分账户的职责不同。用户表关注登录认证积分账户关注账务数据。把两者分开以后如果想加“积分冻结”“积分过期”“积分赠品”这些高级功能改动会小很多。还有一个点零食商品表不要把积分价格设计成数据库里的“decimal(10,2)”类型除非你真的有小数积分需求。大多数业务里积分是整数用int或者bigint就够了后面计算也不会被浮点精度坑到。2.2 积分账户表和流水表的关键字段积分账户表的核心字段大概是iduser_idbalance当前可用积分frozen冻结积分如果后续要做下单冻结功能可以留着version乐观锁版本号处理并发扣减用update_time积分流水表的核心字段是iduser_idchange_type变动类型如签到、兑换、退款、管理员调整等change_points变动积分数值正负表示增加或减少balance_after变动后的余额快照order_id关联订单remark备注create_time流水表里的balance_after非常关键。用户查看自己的积分明细时如果只显示“兑换-50积分”没有变动后余额体验是缺失的。有了这个字段前端展示积分变动历史时每一行都能看到“当前剩余多少”也不需要在查询时现算。另外change_type我建议用字符串枚举存比如SIGN_IN、EXCHANGE、REFUND、ADMIN_ADJUST不要用0、1、2这种魔法数字。虽然数据库空间多占一点点但维护代码时看到change_type3完全不知道什么意思很容易出bug。2.3 订单主表和明细表的关联关系零食订单必须拆成orders和order_item两张表。我第一次设计时图省事直接在订单表里存了个“零食id和数量的JSON字符串”。结果后面查“哪个零食卖得最好”时SQL写得很痛苦。正确做法是orders表只保存订单级别信息比如订单号、用户id、订单总积分、订单状态待处理、已出库、已取消。order_item保存每个零食明细一行一个商品包含snack_id、snack_name快照、snack_image快照、points快照、count。为什么要在明细表里冗余零食的名称和积分快照因为零食商品信息之后可能改名、改价格如果只关联snack_id历史订单显示就会变掉。做销售类系统订单快照是基本常识也是面试时能加分的细节。2.4 开发中踩过的两个表设计坑第一个坑是字段类型用double存积分。积分的数值通常都是整数但有时候会涉及千分之一的汇率之类如果直接用double数据库里很容易出现0.30000000000000004这种值。建议积分字段统一用int或bigint存“积分单位”不要用double。第二个坑是外键约束。一开始我在order_item上直接加外键关联orders主表结果测试时想删掉一个错误订单被外键卡住先删明细再删主表多了一堆麻烦。后来我把物理外键去掉只保留逻辑外键用普通索引字段做关联用代码保证一致性。开发效率高了很多这也是很多生产项目的实际做法。3. SpringBoot后端接口设计、事务和积分并发扣减3.1 项目初始化与目录分层在IDEA里新建项目时我习惯先用Spring Initializr创建基础工程选好Spring Web、MyBatis-Plus、MySQL Driver、Lombok这些依赖。版本这里要注意如果选SpringBoot 3.xJDK必须是17及以上如果机器上还是JDK8老老实实选SpringBoot 2.7.x不然一堆依赖会报版本冲突。后端目录我按最常用的restful方式分包com.example.snack ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config ├── common │ ├── result │ ├── exception │ └── utils这种结构的好处是接口入口在controller业务逻辑在service数据库操作在mapper实体和视图对象分离。积分扣减的事务逻辑写在service里controller保持薄薄一层只做参数接收和结果返回。需要注意entity是数据库实体vo是返回给前端的视图对象dto是接收参数的传输对象。很多人喜欢一个实体类到处用结果数据库字段变更后接口返回值也跟着变前端莫名其妙就崩了。项目稍大一点还是老老实实分层对象。3.2 核心接口设计一个前后端分离项目接口命名要统一。我实际设计时用了这样一组接口方法路径说明POST/api/user/register注册POST/api/user/login登录GET/api/user/points查积分余额GET/api/user/points/logs查积分流水GET/api/snack/list零食列表分页加分类筛选GET/api/snack/detail/{id}零食详情POST/api/order/create积分兑换下单GET/api/order/my我的订单GET/api/order/admin/list管理端订单列表PUT/api/order/status管理端修改订单状态POST/api/snack/save管理端新增或修改零食PUT/api/snack/status管理端上下架接口的返回结构统一用R对象包装里面放code、message、data。不要一个接口返回Map一个接口返回List另一个又返回Entity前端处理起来会发疯。Data public class RT { private Integer code; private String message; private T data; public static T RT success(T data) { RT r new R(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T RT error(String message) { RT r new R(); r.setCode(500); r.setMessage(message); return r; } }3.3 积分扣减不超买的实现方案积分兑换下单是核心中的核心。我最初写的逻辑很简单查询用户积分余额。判断余额是否大于订单所需积分。扣减余额生成订单。但在并发环境下这个逻辑是有问题的。如果两个请求同时读到余额为100一个扣了80另一个也扣了80最后余额变成-60。解决思路有几种我选的是“乐观锁 事务”。核心SQL是UPDATE points_account SET balance balance - #{cost}, version version 1 WHERE user_id #{userId} AND balance #{cost} AND version #{oldVersion}这样一条update语句会在数据库层面保证只有余额充足并且版本号匹配时才扣减成功。然后配合Spring的Transactional把“扣积分”和“插入积分流水”放在同一个事务里任何一个失败都一起回滚。代码逻辑大致是Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListOrderItemDTO items) { // 1. 计算总积分 int totalPoints calcTotalPoints(items); // 2. 扣减积分不传旧版本也可以用 balance cost 做条件 int updated pointsAccountMapper.deductBalance(userId, totalPoints); if (updated 0) { throw new BusinessException(积分余额不足或账户不存在); } // 3. 创建订单 Order order createOrder(userId, totalPoints); // 4. 记录积分流水 pointsLogMapper.insert(new PointsLog(...)); // 5. 返回订单信息 return orderVO; }注意这里的deductBalance方法返回影响行数如果为0说明余额不足或用户账户不存在一定要抛出异常否则后续步骤还是会继续执行。我见过不少人写完update忘了判断返回值结果积分没扣、订单却生成了。另外在这个事务里还要顺带扣减零食库存。扣减库存的SQL和扣积分是相同套路用UPDATE snack SET stock stock - #{count} WHERE id #{snackId} AND stock #{count}影响行数为0就说明库存不足直接抛异常回滚。3.4 JWT登录鉴权实现要点前后端分离项目的登录鉴权我用的是JWT。用户登录成功后后端生成一个token返回给前端前端在后续请求的Header里带上Authorization: Bearer token。SpringBoot端需要写一个拦截器在preHandle里做两件事解析token校验签名和过期时间。把解析出的userId放到ThreadLocal或request attribute里方便后续controller取用。这里有一个容易漏掉的点前端会先发一个OPTIONS预请求如果拦截器直接拦掉所有请求跨域配置又没处理好前端会一直报CORS错误。所以拦截器里要提前放行OPTIONS请求或者在后端配置CorsFilter并且拦截器判断OPTIONS.equals(request.getMethod())时直接返回true。我给一个简化版的拦截器写法Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BusinessException(未登录或token已失效); } String token authHeader.substring(7); String userId JwtUtils.parseToken(token); request.setAttribute(userId, userId); return true; } }4. Vue.js前端搭建自选货架和积分购物车4.1 项目初始化与环境准备前端我用的Vue.js开发工具同样是IDEA里的Terminal。创建项目可以用Vue CLI也可以用Vite我比较推荐Vite作为构建工具启动速度快很多。npm create vitelatest snack-web -- --template vue cd snack-web npm install npm install axios vue-router pinia element-plus这里有个很多人会踩的坑Node版本太老导致Vite初始化失败。建议Node.js版本在16.14以上如果用的是Vue3 Vite5某些新版本甚至要求Node 18或20。装之前先用node -v确认一下。如果用Vue CLI创建项目也可以只是启动速度会慢一些但胜在生态插件多。两种方式我都试过对于这个项目Vite完全够用。4.2 路由、状态管理与axios封装路由方面页面一共有这些登录页、注册页、零食货架页、积分明细页、购物车页面以及管理端的商品管理页、订单管理页。我用vue-router做了路由配置并在路由守卫里判断用户是否登录router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })axios封装上我会创建一个request.js设置baseURL在请求拦截器里把token塞进去在响应拦截器里统一处理登录过期的问题。这样所有页面调用接口时不用每次重复写token逻辑。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default request4.3 零食自选货架页面的实现思路零食货架是这个平台最核心的前端页面。我用Element Plus的el-card做成一张张零食卡片每张卡片上显示零食图片、名称、所需积分和“加入购物车”按钮。页面顶部显示用户当前积分余额这样用户一边逛一边知道自己的“预算”。购物车我建议用Pinia来维护而不是只在组件内部用ref。这样即使切换页面购物车数据也不会丢。Pinia里维护一个cartItems数组提供addToCart、removeFromCart、getTotalPoints方法。下单按钮触发流程是前端先校验购物车不为空。请求/api/order/create携带商品id和数量列表。后端返回订单成功后清空购物车刷新积分余额和积分流水。如果返回积分不足弹窗提示用户。这里要注意前端校验只是辅助真正的余额校验一定是以后端为准。因为前端任何人都能改权限和账务逻辑不能信任前端。4.4 联调时跨域和接口格式的几个坑本地开发时前端跑在5173端口后端跑在8081端口直接请求会跨域。我推荐在Vite的vite.config.js里配置代理server: { port: 5173, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } }这样前端请求/api/user/login开发服务器自动转发到后端8081不需要在后端写CORS配置。如果直接把前端打成静态文件部署到NginxNginx也要配一个/api反向代理到后端服务。还有两个联调阶段常见的问题一是Long类型主键传到前端会丢失精度因为JavaScript的Number安全整数范围有限所以返回给前端的id字段尽量用String序列化或者加JsonFormat转成字符串。二是日期字段后端返回LocalDateTime时默认格式是一串数组需要在字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)统一一下。5. 在IDEA里开发调试这个项目的效率技巧5.1 创建SpringBoot项目的版本选择细节搜索词里有一条“springboot版本太高”这个我太有体会了。很多教程默认让你创建最新版SpringBoot但如果你用的还是JDK8新建出来的SpringBoot 3.x项目在编译时就会提示版本不支持。我的建议是如果JDK是1.8SpringBoot选2.7.x版本Java版本选8。如果JDK是17或21可以放心选3.x。IDEA里通过Spring Initializr新建项目时右侧Server URL可以换成国内镜像地址比如用阿里云的https://start.aliyun.com这样拉取依赖会更快。在Maven的settings.xml里配置阿里云镜像也能避免依赖下载慢到怀疑人生。5.2 热部署和Debug断点调试开发阶段后端每次改代码都重启项目很影响节奏。我引入了spring-boot-devtools开启自动重启改完代码后IDEA会自动重新编译加载。配合application.yml里的spring: devtools: restart: enabled: true这样Java代码变更后项目会自动重启。注意IDEA中需要开启Build project automatically否则devtools感知不到变化。Debug调试时我强烈建议在积分扣减的service方法上打断点观察事务是否生效、扣减SQL的影响行数是多少。用IDEA的Debug模式可以看到每个变量的实时值也能用Evaluate Expression手动执行表达式排查逻辑错误比加日志快很多。5.3 前端在IDEA里打不开或启动失败的处理IDEA里打开前端项目后如果直接点绿色运行按钮不一定能启动Vite或Vue CLI项目。正确做法是在IDEA的Terminal中输入npm run dev或者在运行配置里添加一个npm配置。不要用IDEA内置服务器去跑前端工程它不识别。另外还有两个高频问题端口被占用Vite默认5173后端SpringBoot默认8080如果你电脑上有其他项目占用了端口启动会报错。可以改Vite的server.port或者后端的server.port。Lombok报错如果后端的Data注解没有生成getter/setterMaven里要加lombok依赖并在IDEA里安装Lombok插件。新版IDEA通常自带Lombok支持但在老版本里没装插件会有各种编译错误。6. 从本地到可演示数据库初始化、打包部署和自测清单6.1 数据库初始化与测试数据准备写代码前我一般先用一个init.sql脚本初始化数据库。脚本要包含建库、建表、插入默认管理员账号。为了方便演示我会插入20个左右零食商品数据覆盖饮料、饼干、辣条、糖果等分类并给每个商品配上图片URL和积分价格。演示前一定要确认积分账户里有不小不大的余额比如剩余300积分订单里正好有一个280积分的商品这样用户可以体验“余额充足”和“余额不足”两种提示。管理员账号也需要提前创建好并拥有一个初始密码避免现场再去调接口创建。测试数据这块我还有一个经验零食图片不要都用本地路径最好用一些稳定的在线图片地址或者在图床放一份。这样部署到其他环境后图片一样能显示不然本地路径一换环境就全部裂图。6.2 后端打包和前端部署后端在IDEA右侧Maven工具窗口双击package或者在Terminal中执行mvn clean package -DskipTests打包好的jar包在target目录下直接java -jar snack-server.jar就能跑。生产环境最好把application-prod.yml单独分离把数据库连接改成线上数据库。前端执行npm run build后生成dist目录我通常用Nginx托管server { listen 80; root /opt/snack-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081/api/; } location / { try_files $uri $uri/ /index.html; } }注意部署到Nginx后history路由模式要配置fallback也就是try_files那行否则刷新页面会404。很多人只配了/api的转发忘记配前端路由的fallback一刷新就白屏。6.3 演示前必须自测的五条链路我每次在公开场合演示前都会花十分钟走一遍完整链路避免现场翻车注册一个新用户确认积分账户自动创建且有初始积分。浏览零食列表确认图片能正常显示积分价格没有问题。将商品加入购物车查看总积分是否正确库存不足时是否会提示。提交积分兑换订单确认积分余额被扣减积分流水里出现一条记录。管理员登录查看订单列表把订单状态从“待处理”改成“已出库”用户端刷新后能看到状态变化。这五条链路如果全部通过这个系统基本上就可以拿出去演示或交付了。7. 我对这类全栈项目的一些复盘和真心话项目做完后我复盘了一下整个设计和实现过程。最大的心得是积分制零食自选销售平台表面上很简单但“积分账本 订单 库存”三个环节一旦联动起来数据的准确性和一致性就是第一优先级。像积分扣减这种核心操作宁可多写几行并发保护也不要抱着侥幸心理。另一个经验是代码写清楚、命名规范比堆一堆炫技框架重要得多。这个项目里我没有用复杂的分布式锁、消息队列就用SpringBoot MySQL MyBatis-Plus Vue.js把整个闭环跑通了。面试官问起来我把积分流水设计、乐观锁扣减、订单快照这几个点讲清楚已经能证明自己的工程能力了。最后再分享一个小技巧开发这种双端项目IDEA里可以同时打开后端和前端两个窗口后端开Debug模式前端开着浏览器开发者工具两边配合起来就像开了透视一样。出了bug先用浏览器F12看接口返回再回到IDEA看控制台日志90%的问题都能在这两步之内定位。希望这篇内容对你做SpringBoot Vue.js的积分制零食自选平台有帮助。如果你也在准备类似的Java全栈项目多花点时间在数据库表设计和核心交易逻辑上收获会比想象大得多。