做毕业设计时,我选了“小区疫情购物系统”这个方向。起因很简单,当时疫情反复,小区一封闭,买菜就成了头等大事。与其套一个现成的商城模板,不如从零写一个前后端分离的小区物资采购平台,既能练手SpringBoot+Vue+MyBatis+MySQL这一整套技术栈,又能真正解决一个具体的业务问题。项目做下来,踩了不少坑,也总结了一些经验,这里把完整的设计思路、核心实现和部署过程分享出来,希望对正在做类似系统、或者想学前后端分离的朋友有点帮助。
这个系统不是那种大而全的电商平台,而是围绕“小区”这个小范围场景设计的。用户端只服务本小区居民,商品由小区周边商超或社区团购提供,配送则交给志愿者或物业工作人员。系统分为用户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那套舒服太多。
- MyBatis:SQL可以自己写,尤其是订单统计、联表查询这种,用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、password(BCrypt加密)、phone、role(0普通用户 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、status(0下架 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位”,在并发不高的情况下够用。
- 所有时间字段统一用datetime,Java端用LocalDateTime接收,避免时区混淆。
2.2 后端接口设计与JWT鉴权方案
后端接口我设计成RESTful风格,统一返回一个Result对象:
public class Result<T> { 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放进Header:
Authorization: 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>application.yml里,我在本地环境用的是:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/community_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 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) { // 只查status=1的商品 PageHelper.startPage(page, size); List<Product> 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 id="deductStock"> 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实例的拦截器设置Header:
axios.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 2>&1 &后台运行。 - 前端项目执行
npm run build,生成dist目录。把dist上传到服务器/opt/shop-web/。 - 配置Nginx:
server { 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 中文乱码和时区问题
乱码问题主要出现在两个地方:数据库读不出来中文、前端显示中文乱码。解决办法:
- 数据库连接串加
useUnicode=true&characterEncoding=utf8。 - MySQL表结构默认字符集设为
utf8mb4,因为utf8mb4能存emoji。 - 后端
application.yml里Jackson时间格式化加GMT+8。
时区问题我遇到过:服务器在阿里云,默认时区是UTC,导致订单时间少了8小时。后来在MySQL连接串加了serverTimezone=Asia/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直接映射这个目录。
这里有一个坑:后端接口上传图片需要用到MultipartFile,SpringBoot默认单次上传文件大小限制为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,把每个接口的请求、响应、字段说明都列清楚。联调时你会发现,文档比记忆靠谱得多。我自己在写这个项目时,前前后后改了不下十次接口签名,如果没有文档,很多错误根本没法快速定位。
这个系统后续我还可以继续扩展团购、秒杀、优惠券等玩法,但核心的“前后端分离+完整流程+可部署”已经达到预期。希望这篇分享能帮到正在做类似项目的你。