做毕业设计时我选了“小区疫情购物系统”这个方向。起因很简单当时疫情反复小区一封闭买菜就成了头等大事。与其套一个现成的商城模板不如从零写一个前后端分离的小区物资采购平台既能练手SpringBootVueMyBatisMySQL这一整套技术栈又能真正解决一个具体的业务问题。项目做下来踩了不少坑也总结了一些经验这里把完整的设计思路、核心实现和部署过程分享出来希望对正在做类似系统、或者想学前后端分离的朋友有点帮助。这个系统不是那种大而全的电商平台而是围绕“小区”这个小范围场景设计的。用户端只服务本小区居民商品由小区周边商超或社区团购提供配送则交给志愿者或物业工作人员。系统分为用户App/H5端、管理后台、配送端三个端共享同一套后端API。技术选型上后端用SpringBoot 2.7 MyBatis MySQL 8前端用Vue 2 Element UI Axios鉴权用JWT部署用Nginx反向代理。整个项目无论你是刚学完SSM的初学者还是准备做毕业设计的在校生都可以拿来当参考。1. 系统整体设计与技术选型思路1.1 为什么选前后端分离以及这套技术栈的取舍前后端分离最直观的好处就是三个端可以并行开发。我一个人写当然谈不上“并行”但分离以后逻辑清晰后端只负责提供JSON接口前端只负责渲染页面排查问题时不用在JSP或者模板引擎里翻找混杂的Java代码。尤其是有移动端需求时同一套API可以同时被H5、小程序甚至App调用后面扩展很方便。技术栈的选择我当时是这样考虑的SpringBoot相比传统的SSM它内置了Tomcat简化了配置application.yml里配数据源、端口、JWT密钥比web.xml那套舒服太多。MyBatisSQL可以自己写尤其是订单统计、联表查询这种用XML映射比JPA更直观也方便优化复杂SQL。MySQL免费、稳定小区级别的并发量几百人同时下单完全没问题。Vue 2 Element UI社区资料多遇到问题基本都能搜到组件库比较成熟后台管理界面直接套用布局省心。Nginx用来托管前端打包后的静态文件同时把/api开头的请求反向代理到SpringBoot解决跨域同时隐藏后端端口。这套组合在当时已经是比较主流的“全栈入门配置”。它的缺点也很明显没有Redis缓存没有消息队列高并发会撑不住。但作为一个单体项目特别是毕业设计或小型可用系统完全足够。我自己的定位是“先实现完整功能再谈优化”。1.2 三种角色与核心业务闭环小区购物系统的核心是“人”和“物资”的闭环。我设计了三种角色小区居民用户注册登录后浏览本小区可购买的商品加购物车生成订单在线支付模拟然后等待配送。管理员运营/商超管理维护小区信息、商品分类、商品上下架、订单状态流转、配送员管理、订单统计。配送员志愿者/物业通过配送端查看分配给自己的订单更新配送状态比如“取货中”“已送达”。业务流大概是居民下单 - 管理员在后台看到新订单 - 管理端选择配送员并派单 - 配送员确认接单 - 配送员标记送达 - 居民确认收货。整个流程保证了物资从商超到居民手中的可追踪性。1.3 功能模块划分用户端H5/Web微信登录模拟、手机号注册登录首页商品分类、搜索、轮播图商品详情、加入购物车、立即购买购物车列表、结算页选择地址、备注订单列表、订单详情、确认收货、取消订单超时自动取消个人中心、地址管理、意见反馈管理后台Web登录管理员账号首页数据看板今日订单数、销售额、用户数小区管理、楼栋管理商品管理、分类管理、库存管理订单管理列表、详情、派单、修改状态配送员管理用户管理、公告管理配送端H5/Web待接单/已接单/已送达等不同状态的订单列表订单详情含住户地址、联系手机号一键拨号调用手机拨号协议功能上我优先保证了“商品-购物车-订单-配送”这条主链路的完整公告、轮播图这些属于锦上添花的附加功能。2. 核心细节解析与实操要点2.1 数据库表设计从用户表到订单表的关系梳理数据库是整个系统的地基。我设计表的时候没有一步到位而是先画了ER图再落成SQL。核心表一共有8张user用户表。字段包括id、username、passwordBCrypt加密、phone、role0普通用户 1管理员 2配送员、avatar、create_time。community小区表。字段包括id、name、address、admin_id关联管理员。building楼栋表。字段包括id、community_id、building_no、unit_no。user_address用户地址表。字段包括id、user_id、community_id、building_id、detail_address、receiver_name、receiver_phone、is_default。category商品分类表。id、name、sort_order。product商品表。id、category_id、community_id、name、main_image、price、stock、sales、status0下架 1上架、detail富文本、create_time。cart购物车表。id、user_id、product_id、quantity、checked。orders订单表。id、order_no、user_id、community_id、total_amount、pay_amount、status待支付/已支付/已接单/配送中/已送达/已取消、remark、create_time、pay_time、consignee_name、consignee_phone、consignee_address、logistics_status。order_item订单明细表。id、order_id、product_id、product_name、product_image、price、quantity。外键我建了索引但没有强制物理外键因为后面做分库分表或者换库时逻辑外键更灵活。订单表和订单明细表是一对多查询订单详情时需要join两张表。建表时的几个细节坑金额字段不要用float用decimal(10,2)否则会有精度问题。库存字段stock要加CHECK约束stock 0防止超卖。order_no要唯一索引生成规则我用的是“yyyyMMddHHmmss 随机6位”在并发不高的情况下够用。所有时间字段统一用datetimeJava端用LocalDateTime接收避免时区混淆。2.2 后端接口设计与JWT鉴权方案后端接口我设计成RESTful风格统一返回一个Result对象public class ResultT { private Integer code; // 200成功 401未登录 500错误 private String msg; private T data; }接口按模块划分用户模块/api/user/、商品模块/api/product/、购物车模块/api/cart/、订单模块/api/order/、配送模块/api/delivery/、管理模块/api/admin/。其中需要登录才能访问的接口统一通过JWT拦截器校验。JWT鉴权的流程登录成功后后端生成token存到Redis或直接返回给前端由前端存起来。我这里因为没引入Redis直接用的MySQL存用户的登录状态其实JWT无状态后端只需要用密钥校验即可。前端在Axios请求拦截器中把token放进HeaderAuthorization: Bearer token。后端写一个HandlerInterceptor对非白名单的路径进行校验解析token里的userId放入ThreadLocal供Controller直接用。这里有一个容易出错的地方JWT过期时间设多长我设的是7天因为小区用户不一定是高频使用7天内免登录体验较好。不过出于安全考虑其实应该加“刷新token”机制但当时做的时候为了简化就直接让前端在收到401时跳转到登录页。2.3 前端页面结构与状态管理前端用Vue2 Vue Router Vuex Axios。页面结构大概是用户端pages/login 登录页/home 首页商品列表/product/:id 商品详情/cart 购物车/confirm 确认订单页/order/list 订单列表/order/detail/:id 订单详情/user 个人中心管理端pages/admin/login 登录/admin/dashboard 数据看板/admin/product 商品管理/admin/order 订单管理/admin/community 小区管理/admin/user 用户管理Vuex里我只存了用户信息userInfo和token其他如购物车数据都从后端拉取。这样刷新以后购物车不会丢因为本身存在后端数据库。状态管理上需要注意购物车的加和减是一个高频操作每次点击都调接口更新后端数据再重新拉列表这样虽然实时性强但会有一点网络开销。我实际是“前端乐观更新”点击后先改本地界面再调接口如果失败就回滚。3. 实操过程与核心环节实现3.1 后端环境搭建从pom.xml到application.yml先说后端项目初始化。我是通过Spring Initializr生成的或者直接在IDEA里新建SpringBoot项目勾选Web、MyBatis、MySQL驱动、Lombok。pom.xml里关键依赖是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependencyapplication.yml里我在本地环境用的是server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/community_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.shop.entity configuration: map-underscore-to-camel-case: true jwt: secret: your-secret-key expire: 604800 # 秒7天然后启动类加MapperScan(com.example.shop.mapper)。3.2 核心代码登录、商品列表、下单流程3.2.1 登录接口登录用的是BCrypt加密数据库里存的密码是$2a$10...。登录接口逻辑PostMapping(/login) public Result login(RequestBody LoginDto dto) { // 根据用户名或手机号查用户 User user userService.findByUsername(dto.getUsername()); if (user null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } // 生成JWT String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(new LoginResp(token, user)); }注意BCrypt是单向哈希不能反查所以登录验证只能用checkpw。这种设计比MD5盐更安全现在项目里基本是标配。3.2.2 商品列表接口商品列表需要支持分类筛选、关键词搜索、分页。我用了PageHelper插件也可以手写limit。接口如下GetMapping(/product/list) public Result list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) Integer categoryId, RequestParam(required false) String keyword) { // 只查status1的商品 PageHelper.startPage(page, size); ListProduct products productMapper.selectOnSale(categoryId, keyword); return Result.success(new PageInfo(products)); }商品图片URL我存的是相对路径比如/images/xxx.jpg部署后由Nginx直接映射到静态目录。这样数据库里不会存一堆带域名的东西换域名时不用改数据库。3.2.3 下单流程下单是核心中的核心。我设计成了一个事务方法通过Transactional控制。流程从前端接收一个SubmitOrderDto包含地址id、备注、商品列表[{productId, quantity}]。校验商品是否在售、库存是否足够。计算总金额生成订单号。插入orders表得到订单id。遍历商品列表插入order_item表同时扣减库存。清空购物车中已下单的商品。扣库存的SQL我用了乐观锁防止超卖update iddeductStock UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity} /update如果受影响行数为0说明库存不足直接抛异常回滚事务。这样可以保证库存不会被扣成负数。3.3 前端核心登录页、商品卡片、购物车、订单提交前端项目我用Vue CLI创建配合Element UI。以登录页为例表单校验后调/api/user/login接口成功后把token存到localStorage然后把用户信息存到Vuex。Axios实例的拦截器设置Headeraxios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; });商品卡片我用了一个el-card点击跳转到详情页。为了美观加了简单的图片懒加载指令。购物车页面的逻辑比较复杂因为需要支持勾选、修改数量、删除。我后端设计了两个接口GET /api/cart/list返回购物车列表同时算出合计金额。POST /api/cart/update更新某个商品的勾选状态、数量。每次都把整个购物车重新拉一遍渲染时用computed计算已选数量和总价。订单提交页选择“收货地址”如果之前没有地址则弹出对话框新建。用户提交订单后后端返回订单id然后前端跳转到订单详情页显示“等待支付”这里我只做了一个模拟支付按钮也就是把订单状态从“待支付”改为“已支付”。3.4 部署到云端Nginx SpringBoot MySQL实战部署环节是整个项目从“本机能跑”到“网络可访问”的关键。我选择了一台云服务器2核4G系统是CentOS 7。步骤如下安装JDK 1.8、MySQL 8、Nginx。MySQL数据库初始化后把本地的sql文件上传导入。打包后端项目在项目根目录执行mvn clean package -DskipTests得到target下的jar包。上传jar包到服务器比如放到/opt/shop/。用nohup java -jar shop.jar logs.log 21 后台运行。前端项目执行npm run build生成dist目录。把dist上传到服务器/opt/shop-web/。配置Nginxserver { listen 80; server_name your-domain.com; # 前端访问 location / { root /opt/shop-web; index index.html; try_files $uri $uri/ /index.html; } # API反向代理 location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 图片访问 location /images/ { alias /opt/shop-images/; } }注意try_files这行必须加否则前端用的history模式路由在刷新后会404。如果你不想配置history也可以改前端路由为hash模式这样url里会带#刷新不会404但不美观。4. 常见问题与排查技巧实录我在开发和部署过程中几乎每走几步就踩一个坑。下面按高频程度列出问题和解决思路都是真实记录。4.1 前后端联调时的跨域问题本地开发时前端跑在localhost:8081后端跑在localhost:8080直接发请求会跨域。解决方式有两种后端加CORS配置。前端用Vue CLI的devServer代理。我推荐第二种因为它不污染生产环境。vue.config.js里配devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/product/list开发服务器会帮你转发到后端生产环境用Nginx做类似的事情。如果后端也做了允许跨域要注意生产环境Nginx配置里不要再重复设置跨域头避免重复响应头异常。4.2 中文乱码和时区问题乱码问题主要出现在两个地方数据库读不出来中文、前端显示中文乱码。解决办法数据库连接串加useUnicodetruecharacterEncodingutf8。MySQL表结构默认字符集设为utf8mb4因为utf8mb4能存emoji。后端application.yml里Jackson时间格式化加GMT8。时区问题我遇到过服务器在阿里云默认时区是UTC导致订单时间少了8小时。后来在MySQL连接串加了serverTimezoneAsia/Shanghai同时在Linux服务器执行timedatectl set-timezone Asia/Shanghai解决。4.3 前端打包以后路由404这个在部署Nginx时是最高频的。原因很简单history模式下浏览器直接访问/order/list但服务器上不存在这个物理文件Nginx返回404。解决办法就是刚才说的try_files $uri $uri/ /index.html;。如果你用的是Tomcat部署前端也需要配置相应的rewrite规则。4.4 前端打包后图片不显示我在项目里把商品图放在后端项目的src/main/resources/static/images下面本地可以通过http://localhost:8080/images/1.jpg访问。但部署到服务器后jar包里打包的资源无法直接修改而且正式环境图片应该存独立目录。我最终的方案是图片上传接口管理后台商品添加时保存到服务器本地目录并在数据库里存路径。Nginx直接映射这个目录。这里有一个坑后端接口上传图片需要用到MultipartFileSpringBoot默认单次上传文件大小限制为1MB。我传超过1MB的商品图时直接被拒需要在application.yml里调大spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB4.5 购物车并发下订单导致库存超卖前面提过我用的是乐观锁。实际测试时我用JMeter模拟100并发同时抢购一个库存为10的商品最终只有10个订单成功。这是因为在deductStock的SQL里用stock #{quantity}条件当两个请求同时执行时MySQL的行锁会让它们串行后一个发现stock不足就失败。但要注意这里的工作量是在数据库层面如果你的查询和扣减之间有其他耗时操作最好在业务代码里加分布式锁或Redis预减库存。对于此项目的规模乐观锁已经足够了。4.6 空指针异常用户未登录时查询购物车前端把token传给后端但有时token过期后端解析不到userId或者用户根本没登录就乱调购物车接口。我在Interceptor里已经把token的用户信息放进了ThreadLocal但需要保证Controller里能取到。如果取到null必须返回401而不是继续执行业务。后来我把这个方法抽成了CurrentUser注解用HandlerMethodArgumentResolver来优雅地获取当前用户。5. 扩展建议向生产级功能演进这个系统做成这样已经是一个完整可跑的“毕业设计/课程设计”级别的项目了。但如果未来你想把它真正投入生产,或者作为面试项目展示亮点还有几个方向值得扩展。引入Spring Security或改造成更规范的RBAC权限模型目前我的角色判断只是简单地在Interceptor里判断role字段不够灵活。用Redis缓存商品分类和热门商品信息减少数据库压力。引入消息队列比如RabbitMQ处理订单超时自动取消和部分业务异步操作。增加支付功能接入微信支付/支付宝沙箱或者至少做一个模拟支付的表单。引入分布式文件存储比如OSS保存商品图片而不是本地目录。增加配送轨迹追踪通过定时更新配送员经纬度前端地图显示配送路径。如果你时间充裕挑其中一两个方向认真做深面试时讲述的思路会非常加分。最后再分享一个我个人的小技巧在做类似这种全栈项目的时候一定要把接口文档写出来。不用用到复杂的工具一台文档平台或者直接在项目里放一个docs/api.md把每个接口的请求、响应、字段说明都列清楚。联调时你会发现文档比记忆靠谱得多。我自己在写这个项目时前前后后改了不下十次接口签名如果没有文档很多错误根本没法快速定位。这个系统后续我还可以继续扩展团购、秒杀、优惠券等玩法但核心的“前后端分离完整流程可部署”已经达到预期。希望这篇分享能帮到正在做类似项目的你。