做了个小区的社区订餐系统,前后端分离,SpringBoot做后端服务,Vue做前端页面,从需求分析到部署上线算是完整走了一遍。这篇就把整个项目的设计思路、核心模块的实现细节、还有实际开发中踩过的坑整理出来,给准备做类似管理系统的同学一个参考。
先说下这个项目是干什么的。社区订餐系统,核心就是解决小区居民吃饭的问题:周边的小餐馆、食堂、居民自己在家做的私房菜,都可以通过平台接单,用户在小程序或者网页上就近选择、在线下单、到店自取或者配送到家。听起来简单,真正动手做才发现,光订单状态流转、权限控制、菜品上下架这些基础功能就能写不少代码,如果再加上购物车、优惠券、配送员接单、后台数据统计,一个完整的系统下来工作量相当可观。
1. 项目概述与需求拆解
1.1 社区订餐系统到底解决什么问题
社区订餐和普通外卖平台最大的区别在于服务半径和信任关系。普通外卖覆盖整个城市,用户面对的是陌生商家;社区订餐服务的是同一个小区或者周边几个小区,用户和商家之间往往有地理上的亲近感,可能是楼下开了多年的小馆子,也可能是邻居做的私房菜。这种贴身服务的模式,对系统的要求不是大而全,而是反应快、操作简单、订单流转清晰。
从需求层面拆解,社区订餐系统要实现三类角色:
- 普通用户:浏览菜品、下单支付、查看订单状态、评价晒单。
- 商家:管理菜品(上下架、改价格、调库存)、接单/拒单、打印小票、查看当日营收。
- 平台管理员:审核商家入驻、处理用户投诉、查看平台整体交易数据。
这三类角色的操作场景差异很大,最直观的表现就是前端界面完全不同。用户端要清爽简洁,购物车交互要顺手;商家端要突出“今日订单”和“待处理事项”,恨不得打开App第一眼就能看到有几单需要接;管理后台则更看重数据表格和筛选统计。
1.2 核心业务流程梳理
订餐系统的核心是订单流转,把这条线理清楚,后面的开发就顺了一半。
用户的操作流程:进入首页选择商家 → 浏览菜品并加入购物车 → 确认订单(选地址、选配送时间) → 在线支付(模拟支付或实际接入微信支付) → 等待商家接单 → 商家出餐/配送 → 用户确认收货 → 评价。
商家的操作流程:收到新订单提醒 → 接单或拒单 → 出餐后更新订单状态为配送中 → 配送完成后订单关闭 → 查看当日账单。
这个流程看似简单,但涉及一个关键设计:订单状态机。我一开始没太重视,直接在代码里用if-else判断状态,后来发现订单状态一旦多了,判断逻辑就乱成一团。比如“待支付”状态下的订单,用户取消后变成“已取消”,但如果此时用户已经支付成功,取消请求就得先走退款流程,状态变成“退款中”。这些分支在if-else里写多了,代码根本没法维护。
1.3 为什么选SpringBoot+Vue而不是别的组合
选型的时候其实也纠结过。后端可选SpringBoot、Django、Express,前端可选Vue、React,但最终定了SpringBoot+Vue,主要还是基于三点考虑:
第一,SpringBoot的生态太成熟了。社区订餐系统涉及的Spring Security(登录认证)、MyBatis-Plus(数据访问)、Redis(缓存和分布式Session)、RabbitMQ(订单消息通知)、Quartz(定时任务)这些组件,在SpringBoot下都有非常成熟的整合方案,资料也最多,遇到问题基本搜一下就有答案。
第二,前后端分离是未来的主流方式。后端只提供RESTful API,前端独立开发、独立部署。这个项目里前端可以部署在Nginx上,后端跑在独立的服务器上,相互之间只通过HTTP/JSON交互,后期要扩展小程序端,后端API几乎不需要改动,只要小程序端复用后端接口即可。
第三,Vue的上手曲线相对平缓,而且社区生态足够强。Element UI组件库可以直接拉起来一套后台管理界面,Vue Router处理页面跳转,Pinia或Vuex管理全局状态,Vue的响应式机制让购物车这种交互做起来特别顺手。
2. 系统整体设计与技术选型
2.1 前后端分离架构与项目结构
这个项目的整体架构可以用一句话概括:前端展示层 + 后端服务层 + 数据存储层。前端和后端彻底分离,各自独立部署,通过接口通信。
后端项目的包结构是这么设计的:
com.example.order ├── controller # 控制层,接收前端请求 ├── service # 业务层,处理具体业务逻辑 ├── mapper # 持久层,数据库操作 ├── entity # 实体类 ├── dto # 数据传输对象 ├── vo # 视图对象 ├── config # 配置类 ├── utils # 工具类 ├── interceptor # 拦截器 └── common # 通用返回结果、异常处理一眼看过去平平无奇,但实际开发中这个分层帮了大忙。重点说下DTO和VO的区别,很多新手容易混淆。DTO是接收前端传过来的数据,比如用户下单时传过来的订单项列表;VO是返回给前端的数据,比如订单详情页需要的订单状态描述、菜品图片地址等。如果不做区分,直接用Entity接收前端参数或者直接返回Entity,会带来两个问题:一是前端多传的字段会直接被映射到实体上,有被恶意篡改的风险;二是中文注释写多了,前后端联调时会看到很多不该暴露的字段,比如数据库里的创建时间、逻辑删除标记这类信息,暴露出去既不安全也增加不必要的数据量。
前端项目用的是Vue 3 + Vite + Element Plus + Pinia。Vite的启动速度比Webpack快很多,开发体验好;Pinia做全局状态管理比Vuex更简洁,模块化的方式也更清晰。项目分为三个端:用户端(移动端适配)、商家端(桌面端)、管理后台(桌面端),共用一套组件库,但路由和功能完全独立。
2.2 数据库设计与核心表结构
数据库设计是整个项目的地基,这块做得不好,后面写接口、做统计都会很难受。订餐系统的核心表有这几张:
用户表(user):用户ID、微信OpenID(如果接微信登录)、手机号、昵称、头像、余额、积分、创建时间。社区订餐的用户以小区居民为主,手机号是必填字段,后续做订单通知要用。
商家表(merchant):商家ID、商家名称、LOGO、公告、起送价、配送费、营业状态(营业中/休息中)、评分、月销量、审核状态。特别要提的是“营业状态”这个字段,商家可能在高峰期手动关闭接单,这个状态必须实时更新,前端首页商家列表要能即时感知。
菜品表(dish):菜品ID、商家ID、菜品名称、图片、描述、价格、分类(热菜/凉菜/主食/饮料)、月销量、上下架状态、库存。菜品图片存的是MinIO的对象存储地址,而不是直接存图片二进制。餐品图片搞一个固定桶,后端做了访问权限控制,避免图片地址被直接扒走。
订单表(orders):订单号、用户ID、商家ID、订单状态、支付方式、支付时间、配送方式(自取/配送)、收货地址、订单备注、实付金额、配送员ID(如果平台涉及配送员角色)、创建时间、完成时间。订单号用的不是自增ID,而是生成的业务订单号,格式是日期+随机数,比如20240601XXXXXXXX,这样做的好处是订单号本身就携带了日期信息,用户查单、商家对账、客服沟通时拿着订单号就能知道大概的下单时间,而且避免了自增ID被恶意遍历抓取的风险。
订单明细表(order_item): ID、订单ID、菜品ID、菜品名称、菜品图片(下单时的快照)、单价、数量。这里有个很关键的细节:菜品名称和图片在订单明细里必须单独存一份快照,而不是下单的时候去关联菜品表查。原因是商家可能会修改菜品名称、换图片甚至删除菜品,如果不做快照,用户翻历史订单时看到的是商家改过的名称和图片,容易引发纠纷。
购物车表(cart):用户ID、商家ID、菜品ID、数量。购物车按商家维度区分,不能把不同商家的菜放在一个单里,这是订餐系统的基本规则。
配送地址表(address):用户ID、联系人、手机号、小区楼栋门牌号、默认地址标记。社区场景下地址信息可以细化到“XX小区X栋X单元XXX室”,前端做了一个简单的省市区联动选择器。
评价表(comment):订单ID、用户ID、商家ID、评分(1-5星)、内容、图片、回复。
优惠券表(coupon):优惠券模板ID(满减规则)、用户领取记录。
这些表之间的关联关系,核心就是用户→购物车→订单→订单明细→评价这条主线。数据库的索引设计上,订单表要给“用户ID+创建时间”建联合索引,因为用户端最常见的是“查看我的历史订单”;商家表给“经度、纬度”建索引,因为首页拉取附近商家需要按地理位置过滤;订单明细表给“订单ID”建索引,这是理所当然的,但容易漏。
2.3 权限认证与接口安全
社区订餐系统涉及用户、商家、管理员三种角色,权限模型不能简单用一张用户表就解决。这个项目的做法是:一张用户表,增加role字段区分角色,同时用Spring Security做认证和授权框架。
认证流程用的是JWT + Redis的组合方案。用户登录成功后,后端生成JWT令牌返回给前端,前端把Token存储在本地存储中,之后每次请求都在请求头中携带Token。JWT的有效期设置为24小时,同时把Token对应的用户信息存一份在Redis中并设置同样的过期时间。这样做的好处是:用户修改密码或者管理员封禁用户时,可以直接删除Redis中的Token记录让用户的登录立即失效,而不需要等JWT自然过期。这个细节在实际项目中很实用,否则封禁一个违规商家,他的Token还能继续用一天,问题就大了。
接口权限控制上,后端的做法是:给不同角色分配不同的访问权限。比如/api/merchant/**路径下的接口只有商家角色可以访问,/api/admin/**只有管理员可以访问。Spring Security中通过注解@PreAuthorize("hasAuthority('merchant')")来控制。
接口层面还做了两层防护:一是参数校验,前端传过来的数据必须经过校验才能进入业务逻辑,比如手机号的正则校验、订单金额不能为负数、菜品数量必须是正整数,这些用Bean Validation的注解就可以实现;二是统一的异常处理,后端抛出的所有业务异常都由一个全局异常处理器捕获,统一返回JSON格式的错误信息,前端拿到错误码后给出对应的提示。业务异常别直接抛500错误,接口设计为返回错误码+错误信息的结构,前端根据错误码做不同处理,之前吃过亏,某次用户下单时库存不足直接后端报错,返回了一屏英文堆栈,用户看到后体验非常差。
3. 核心功能模块实现
3.1 用户端:浏览、购物车与下单
用户端是移动端适配的页面,首页做了商家列表,按距离排序。这里有个实现细节让我印象很深:距离计算不能在前端做。一开始想过在前端通过腾讯地图SDK定位后调用逆地理编码接口,将经纬度传给后端再计算,但后来发现用户在小区里根本没有打开过地图授权。最后方案是:通过微信登录时授权获取用户所在小区,后端管理员维护小区和坐标的映射关系,用户登录后直接定位到所在小区,商家列表按数据库中的经纬度字段做距离排序。后来项目里也可以选择通过地图选点来定位,方案保留着接口,扩展性更好。
购物车设计上,前端用Pinia做状态管理,购物车不频繁请求后端,本地直接操作。每次加入购物车、改变数量、删除菜品时,将整个购物车同步给后端。同步的目的不只是保存数据,而是做校验:菜品是否还能买(上下架状态、库存是否足够)——这个校验必须在后端做,否则用户直接把购物车数量改成9999,下单接口就要出问题。
下单流程中比较核心的是订单确认页。用户从购物车跳转到确认页,前端把订单预览数据传给后端,后端返回实付金额、配送费、优惠明细。这里必须要后端算钱,前端只能展示。原因很好理解,前端传过来的总金额是可以篡改的,如果商家把一个200元的菜改成0.01元下单,后端又没有重新计算,平台就亏大了。所以后端必须根据购物车里的菜品单价、数量重新计算一遍,再对比前端传过来的金额,如果不一致,后端直接拒绝下单。
3.2 商家端:接单与出餐的核心逻辑
商家端是整个系统里业务逻辑最重的一块。商家登录后进入工作台,首先看到的是今日订单概览:待接单数量、配送中的数量、今日营收、今日订单总数。这四个数字用四条SQL就能查出来,但要注意一个问题:查询2024年6月1日的订单时,时间范围是2024-06-01 00:00:00到2024-06-01 23:59:59,最容易出错的是边界问题。如果直接写where create_time = '2024-06-01',那只能查到零点那一秒的订单。正确写法是取起始日期和结束日期的开闭区间。
接单环节是商家投诉的高发区。商家看到新订单后需要立即处理,这个项目里用了RabbitMQ做订单消息队列。用户支付成功后,后端发一条消息到队列,商家端通过WebSocket即时收到新订单提醒。这个设计比商家端轮询接口要实时得多——轮询接口每隔几秒查一次,用户体验不好也给后端增加无谓的压力。需要WebSocket和RabbitMQ的配合:用户支付成功 → 订单服务发送MQ消息 → 消息监听服务收到后推送WebSocket给商家端 → 商家端弹窗提示。
商家拒单的情况也要考虑:商家点击拒单后,系统自动给用户退款并发送通知。当时在退款逻辑上踩过一个坑:退款必须有幂等性保障,同一个订单如果商家连续点了两次拒单,退款接口不能被调用两次。我的做法是在订单表加一个refund_status字段,每次退款前先查这个字段,如果已经是退款中或已退款状态就直接拒绝,同时利用数据库的唯一约束保证并发情况下也只有一次能成功。
菜品管理相对简单,主要是信息的增删改查,但要注意一个细节:商家修改菜品价格后,正在编辑购物车的用户怎么办?答案是不用特殊处理,用户下单时后端按最新价格计算即可,但要给用户一个提示。后来在订单确认页加了菜品价格变动提示,购物车里的菜如果价格有变化,会显示原价划线加新价格,这样用户体验会好很多。
3.3 管理后台与订单状态机设计
管理后台是平台管理员使用的,核心功能是商家审核和数据统计。商家申请入驻后,管理员需要审核营业执照、食品经营许可证等资料。这里做了一个简单的文件上传功能,图片上传到MinIO,管理员审核页直接用Vue的图片预览组件展示上传的证件照片。
数据统计页用的是ECharts图表,展示每日订单趋势、各商家销量的Top10、菜品分类占比。做数据统计时注意聚合查询的性能,如果数据量大,尽量用SQL的GROUP BY而不是在Java代码里循环统计,后一种方式在数据量小的时候看不出问题,一旦订单量上来,接口响应时间会成倍增加。
订单状态机是整个订单模块的骨架,我单独设计和实现了一套状态流转逻辑,保证任何状态下只能流转到合法的下一个状态:
待支付 → 已取消(用户主动取消/超时未支付系统自动取消) 待支付 → 待接单(用户支付成功) 待接单 → 已接单(商家接单) 待接单 → 已退款(商家拒单) 已接单 → 配送中(商家出餐并开始配送) 配送中 → 已完成(用户确认收货) 配送中 → 已完成(配送员标记送达) 已完成 → 已评价(用户填写评价)状态机的实现没有用太复杂的技术,就是在订单实体类中维护一个状态字段,写一个订单状态流转的Service,每次变更状态前先校验合法性。比如订单处于“待接单”状态,别人想直接调用接口把订单改成“已完成”,这种非法操作会被直接拒绝。给订单Service接口写单元测试时,把每个状态流转的可能路径都覆盖一遍,这块逻辑看似简单,但一旦有疏漏,订单状态就乱了,用户和商家都会被逼疯。
3.4 购物车、优惠券与支付模块的细节
优惠券听起来简单,做起来也有不少讲究。优惠券分两类:新人券(满20减5)和满减券。核心是“券的领取和使用”要防止超领超用,主要靠Redis的原子操作来实现:领券用incr检查领取数量上限,用券时先判断用户是否拥有这张券再标记为已用,这两个操作必须原子完成,不然并发请求下用户可能领到超过平台预算的券量。
支付模块,实际项目中可以接微信支付或支付宝支付,毕设项目中如果只是演示,可以用模拟支付代替:用户点击支付后弹窗提示“支付成功”,后端直接把订单状态改成待接单即可。接入真实支付时,注意回调通知的安全性:回调接口不能加自定义鉴权头(支付平台不会自动带自定义头),必须使用平台提供的验签机制验证回调的合法性。这里容易埋雷,回调接口如果不验签,任何人都可以伪造支付成功的通知,然后白嫖商家的订单。
4. 实操过程与关键环节
4.1 从零搭建SpringBoot后端项目
项目创建我用的IDEA,通过Spring Initializr生成基础工程。JDK用1.8还是11?现在主流推荐JDK 11甚至17,但因为团队里有人习惯JDK 8,所以折中用了JDK 11,性能和中文字符编码方面都比JDK 8要好。Spring Boot版本选的2.7.x系列,对应MyBatis Plus、Redis、RabbitMQ的兼容性最好。别选Spring Boot 3.x,除非你确定依赖都兼容,否则光是javax到jakarta的迁移就够折腾一阵子。
基础的依赖配置是这样的:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>项目配置上,application.yml文件是核心。有几个容易踩坑的点:
spring: datasource: url: jdbc:mysql://localhost:3306/community_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 1 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0MySQL连接串的serverTimezone如果不设置成Asia/Shanghai,在服务器上跑可能会出现8小时时差,所有订单时间都对不上。MyBatis Plus的逻辑删除配置也至关重要,没有它,删除菜品会执行物理删除,用户历史订单里的关联数据就没了。
4.2 Vue前端环境与页面开发要点
Node.js版本建议用16.x或18.x,安装Vue CLI或者直接用Vite创建项目。我用Vite创建了项目:
npm create vite@latest community-order-web -- --template vue cd community-order-web npm install npm install element-plus axios pinia vue-router npm run devVue 3的Composition API是主流,项目里用的是<script setup>语法,比Options API简洁很多,逻辑复用用自定义Hook(也就是Composable)。注意Vue 2和Vue 3差别很大,别混着学;Vue 3的组件通信、插槽、响应式API都有变化,用Vue 3的项目就专注Vue 3,遇到问题搜索时记得加“Vue3”关键词。
前端路由这块有个需求:页面访问权限控制。未登录用户只能访问首页和商家详情页,点击加入购物车或下单时跳转登录页;商家端路由需要商家身份才能访问。实现方式是在Vue Router中配置路由元信息meta,在全局前置守卫中检查用户的Token和角色信息。用户信息存在Pinia的store里,刷新页面时从本地存储中恢复。注意刷新页面后Pinia中的数据会丢失,需要持久化存储,我用的pinia-plugin-persistedstate插件,把用户信息保存到本地存储,刷新后自动恢复。
接口封装上,axios实例统一处理BaseURL、请求拦截、响应拦截。请求拦截器统一读取存储中的Token并加入到请求头,响应拦截器做统一错误处理——当状态码为401时清除用户信息并跳转登录页。这套代码写一次,三个端(用户端、商家端、管理后台)都能复用。
开发中很实用的一步是Vue DevTools的安装和使用,浏览器插件安装后可以直接查看Vue组件的props、data、store状态,排查问题效率提升一大截。我在控制台里看到最常见的错误就是Cannot read properties of undefined,十有八九是数据没加载完成就渲染了,解决办法是用v-if或者可选链操作符?.做空值处理。
4.3 打包、部署与上线
后端打包用的是Maven,直接在项目根目录执行:
mvn clean package -DskipTests打包后在target目录下生成community-order-0.0.1-SNAPSHOT.jar文件。JAR包可以直接通过java -jar命令运行,但在服务器上建议用nohup方式后台运行:
nohup java -jar community-order-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > app.log 2>&1 &一定要设置生产环境的配置文件。我把application-prod.yml单独拎出来,配置生产环境的数据库地址、Redis地址、日志级别,跟本地开发环境彻底隔离。有一次差点出事:开发环境的数据库密码是明文写在配置文件里,如果直接把开发环境配置打包部署到生产环境,数据库连接串会指向开发库——虽然只是内网,但当时确实吓出一身冷汗。
前端部署到Nginx,打包命令是:
npm run build生成dist目录,把dist目录下的文件拷贝到Nginx的html目录,然后配置Nginx反向代理:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1: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 / { try_files $uri $uri/ /index.html; } }Nginx配置里有几个关键点:/api/开头的请求反向代理到后端Java服务;try_files配置是Vue Router的history模式所必需的,否则刷新非首页路由时会报404。如果你用的是hash模式(URL里带#),就不需要这个配置,但hash模式不够美观,建议直接上history模式。
后端服务如果没有公网IP,也可以用内网穿透工具做联调测试,但生产环境还是老老实实买一台云服务器。服务器配置1核2G就够跑这个项目,MySQL、Redis、RabbitMQ、MinIO都装在上面。这里注意服务器的防火墙和安全组规则,只开放需要的端口(22/80/443),数据库端口3306千万别对公网开放,这是个常识性问题,但每年都有大量数据库被扫库泄露的事件,不是危言耸听。
4.4 只有JAR包时怎么还原项目结构
实际交接中可能会遇到一种情况:公司或者学长给你一个打好的JAR包,让你在上边改需求,没有源码。这时候需要反编译来还原项目结构。SpringBoot的JAR包结构比较特殊,里面是BOOT-INF/classes(业务类)、BOOT-INF/lib(依赖库)、BOOT-INF/classpath.idx等目录。常见的反编译工具是IDEA自带的Java Decompiler和CFR。
处理思路是这样的:
先解压JAR包,拿到classes目录下的.class文件,再用IDEA打开这些class文件,IDEA会自动反编译成可读的Java代码。不过反编译出来的代码有几个问题:注解保留得还行,但局部变量名会变成var1、var2这种,注释全部丢失,lambda表达式会被还原成匿名内部类的形式,工程上的原始目录结构(比如src/main/java)需要手动重建。反编译代码可以参考逻辑,但别指望拿过来就能直接编译运行。
这里多说一句,反编译仅限用于学习、还原自己维护的项目,或者经过授权的开源项目,商业化项目直接用反编译手段去还原源码是有法律风险的。遇到这种情况最稳妥的做法是联系原作者要源码,或者基于接口文档重新实现业务逻辑。
5. 常见问题与排查技巧实录
5.1 跨域请求报错
前后端分离项目开发时最容易遇到的第一个问题:前端请求后端接口,浏览器报跨域错误。开发环境下Vite的默认端口是5173,后端是8080,端口不同,跨域就发生了。
解决方法有两个:一个是在SpringBoot后端加跨域配置(全局CORS配置),一个是在前端用Vite的代理。我两种都用过,开发环境推荐用Vite代理,生产环境用Nginx反向代理,这样后端代码里不用写任何跨域逻辑。后端如果非要配CORS,记得在Spring Security的配置中也要放行OPTIONS预检请求:
@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); } }生产环境在Nginx里配了反向代理后,前后端同域,跨域问题就消失了,这种方式最省心。
5.2 前后端时间格式不一致
联调过程中发现前端展示的订单时间跟数据库里差了8个小时,或者浏览器上显示的是一串数字而不是日期格式。原因是后端返回的时间是java.util.Date类型,Jackson默认序列化成时间戳,而前端期望的是yyyy-MM-dd HH:mm:ss格式。
解决办法是在后端统一配置日期格式化:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder -> { builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }同时还要注意前端传给后端的日期字符串也要能正确反序列化,否则前端传个“2024-06-01 12:30:00”过来,后端解析失败直接报400。用@JsonFormat注解也可以,但全局配置一次搞定,避免每个实体类都加注解。
5.3 并发扣库存导致超卖
社区订餐最怕的一种场景:某个商家的招牌菜库存只有10份,结果100个人同时下单,最后卖出去了20份。这就是超卖问题,根源是数据库的读-改-写操作不是原子的。
我当时用的解法是对菜品库存字段做原子更新:
UPDATE dish SET stock = stock - #{count} WHERE id = #{dishId} AND stock >= #{count}这条SQL的意思是:只有当库存大于等于购买数量时才扣减,并且把扣减操作放在一条SQL里完成,MySQL的InnoDB引擎对这条UPDATE会加行锁,天然保证并发安全。执行后判断受影响行数,如果为0说明库存不足,直接返回“库存不足”的提示给用户。
这里有一个常见的错误做法是:
// 先查库存 int stock = dishMapper.getStock(id); if (stock >= count) { // 再扣减 dishMapper.decreaseStock(id, count); }这种先查后改的操作中间有一个时间窗口,并发请求进来时,两个线程都查到了库存还有10份,然后都去扣减,就超卖了。正确的做法就是一条UPDATE解决,不需要额外的事务控制。
5.4 数据库连接池耗尽
项目上线运行了一段时间,有天突然发现接口全部超时,日志里报HikariPool-1 - Connection is not available。排查过程是这样的:数据库连接池默认最大连接数是10,如果某个接口查询执行时间过长(比如统计报表查询了3秒钟),高并发下同时有几十个请求进来,连接池很快就满了,后续请求全部排队等待连接释放。
通过慢查询日志定位到是订单统计接口的SQL没有优化好——用了全表扫描。解决方法是加索引、优化SQL、分页查询。同时把HikariCP的最大连接数调大了一点:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000也不建议过大,连接池本身消耗数据库资源,20个对这个体量的项目足够。关键是避免慢SQL占用连接池。
5.5 Redis缓存与数据库一致性问题
首页的商家列表是高频访问的数据,每次都查数据库扛不住,我加了Redis缓存。一开始是查不到数据就查数据库回填,商家改了菜品价格或营业状态后用户端却不刷新——缓存没失效。后来简单处理:商家修改了营业信息时,主动删除对应商家的缓存,用户端下次访问时重新回填。虽然不是最严格的最终一致性方案,但对这个项目来说够用。
使用Redis时还有两个容易踩的坑:一是缓存穿透,用户查一个根本不存在的商家ID,每次都穿透到数据库,解决方式是缓存空值并设置短的过期时间;二是缓存雪崩,大量缓存同时过期导致数据库压力骤增,我给不同的缓存的过期时间加一个随机值,避免同一时刻大面积失效。
5.6 文件上传与图片显示异常
菜品图片上传后显示不出来,这个问题排查起来有点隐蔽。图片上传到MinIO没有问题,但前端的图片地址是MinIO的地址,而MinIO端口没对外开放,或者文件桶的访问权限设置不对,出现了403。解决方式是给MinIO桶设置公共只读权限,或通过后端接口代理读取图片。安全起见,我采用了后端接口转发的方式,这样图片地址不会暴露MinIO的内网地址,也方便以后做防盗链。点击图片响应慢的话,在后端做了缩略图处理,列表页不加载原图,前端用Vue的懒加载指令处理,滚动到可视区域的时候才去加载。
6. 写在最后的几点体会
这个系统做下来,最大的收获不是掌握了某几个框架的用法,而是对“完整项目”这件事有了体感。学校里的课程作业通常只关注某个功能点能跑通,但真实项目要考虑状态流转、权限边界、并发冲突、数据一致性、部署运维,这些问题课本里不会一次性告诉你,只有踩过坑才记得住。
在个人实操中,有两个体会想分享给准备做类似项目的同学:
第一,开发顺序非常重要。不要一上来就写代码,先把数据库表设计好、接口文档定义好、状态机梳理清楚,再动手写代码。这个项目前期在设计上多花了一周时间,后期开发反而很快,中间几乎没有推翻重来的情况。反过来,先写代码再做设计,八成要返工。
第二,遇到问题先看日志,再看代码,不要凭感觉猜。SpringBoot的日志输出信息量很大,异常堆栈会直接告诉你错在哪一行;排查接口问题用Postman或者Apifox先单独测试后端接口,确认后端没问题再定位前端。很多“前后端联调不一致”的假象,其实都是前端把参数格式传错了,先把请求参数打印出来,比双方扯皮高效得多。
最后再说一个扩展方向。这套系统的后端API设计时已经考虑了多端复用,目前只做了Web端,但如果要接小程序端,后端基本不用改,小程序端直接调API即可。配送员角色也可以集成进来,在订单流转中增加配送路线规划和骑手接单的环节。希望这篇能帮你少走弯路,快去动手做吧。