资讯动态

SpringBoot+Vue3前后端分离实战:小区购物系统从0到1

发布时间:2026/10/14 21:47:41 来源:尧图企业网站定制
前两年小区临时封闭那段时间买菜是真的难。朋友所在的小区靠业主群Excel接龙买菜统计翻了车漏单、重复单一堆志愿者整整两天都在对着表格对账。我调研完这个场景后动手写了一套“小区购物系统”后端用 SpringBootMyBatis前端用 Vue3数据放 MySQL前后端完全分离。系统做出来后居民可以线上选套餐下单管理员在后台审核订单、配货、标记配送状态整条采购配送闭环跑得很顺。这篇文章我会尽量还原这个项目的从 0 到 1 过程业务模型怎么拆、数据库表怎么设计、后端事务与并发怎么实现、前端状态怎么管理、Nginx 怎么部署以及实际开发过程中踩过的一堆坑。如果你刚接触 SpringBoot 和 Vue3跟着文章能把项目骨架搭起来如果已经有点经验重点看第 4 章和第 7 章那是我觉得真正有价值的部分。1. 项目定位与业务模型拆解1.1 业务痛点从一张接龙Excel说起这个项目最初的需求非常朴素就是让居民不用出门也能把生活物资买上。真正动手前我在志愿者群里蹲了一天仔细观察他们怎么处理订单。群里用的是 Excel 接龙一个楼栋一个 Sheet居民自己往表格里填房号、商品、数量。看起来简单实际统计起来头很大有人填错房号有人重复接龙有人改备注改一半还有年纪大的住户看不明白表格直接在群里发文字消息。志愿者的工作则是把几百条记录手动汇总成配货清单再去采购、分装、送到楼下。整个过程两三个人忙一整天还经常漏送。所以这个系统的核心目标并不是“做个电商网站”而是把“收集订单、汇总采购、分装配送”这条原本靠人工的链路变成线上自动流转。只要居民下单准确后台就能按楼栋、按商品自动生成汇总单配货员拿着汇总单备货效率能提升一大截。于是我把业务模型拆成三条链路居民端的购物链路、管理端的配货链路、数据层的统计链路。购物链路解决“怎么买”配货链路解决“怎么送”统计链路解决“每天该备多少货”。后续的表结构、接口设计全部围绕这三条链路展开。1.2 三种角色与业务边界系统初期我规划了三种角色普通居民、小区管理员、系统管理员。后来实际落地时发现如果把配送员也作为独立角色整个权限模型会复杂不少。考虑到小区规模不大我把“配送”拆成了一个状态字段没有单独建配送角色。这样做的好处是接口参数少、模型简单代价是如果后续配货员和管理员不是同一批人就必须升级成独立角色。这个权衡值得所有人在设计阶段想清楚规模小就别过度设计等业务量上来了再拆分也不迟。居民的边界很清晰看商品、加购物车、下单、取消未支付订单、确认收货。管理员的边界是商品上下架、分类维护、查看订单、审核订单、标记配送状态、查看统计。系统管理员在系统里负责账号初始化和授权因为小区场景角色少我直接把他和管理员合并了没有引入复杂的 RBAC 权限模型。权限模型做得太重对这个项目反而是负担。1.3 为什么我不选 JSP而是坚定前后端分离如果只是给一个小区临时使用后端直接用 JSP 渲染页面也完全够用开发速度甚至更快。但是我的判断是这类系统很可能会被复制到多个小区后续很可能要做小程序或移动端。这时候接口化比页面化重要得多。前后端分离以后后端只提供 RESTful 接口前端换小程序、App 都不需要动业务逻辑。另一个原因是团队协作。我虽然是一个人写前后端但分离之后代码边界非常清晰前端只管页面和交互后端只管数据和事务。联调时出现问题马上能定位是哪个模块的锅。对比一下传统 JSP 混写模式找 Bug 时经常在一个页面脚本和 Java 代码交错的问题里绕半天那种体验真的很糟糕。而且前后端分离部署起来也干净前端静态资源由 Nginx 托管后端打成一个 jar 包独立运行环境解耦升级互不干扰。2. 技术选型与架构设计2.1 后端技术栈SpringBootMyBatisMySQL后端技术栈一开始没有太多纠结。SpringBoot 2.7.x 搭配 JDK8是我常用的组合稳定、生态成熟遇到问题一搜就能找到解决方案。不用 SSM 那套手写配置的原因很简单SpringBoot 的自动装配能省掉大量样板配置一个spring-boot-starter-web加上spring-boot-starter-jdbc就能把工程跑起来。MyBatis 是我专门考虑过的选择。很多人现在更愿意用 MyBatis-Plus但我觉得这个项目的核心是订单查询、库存更新、统计报表这类灵活 SQL原生 MyBatis 写动态 SQL 时更直观。尤其是后面第 7 章要讲的库存扣减需要stock ?这种条件更新XML 里写起来比 Wrapper 更可控。当然如果你不想手写大量 SQL集成 MP 也不冲突把 Mapper 换成 BaseMapper再留几个自定义方法就能兼顾。MySQL 8.0 是这个规模项目最合适的关系型数据库。部署不用担心授权问题建表语句通用索引设计也简单。有朋友建议过用 PostgreSQL但考虑到很多中小团队的运维经验都在 MySQL 上我最终选了 MySQL。这个项目的 SQL 基本是标准语法后面要换库压力也不大。2.2 接口规范统一响应体前后端联调最怕什么后端这个人返回{status:1}那个人返回{code:200}第三个人抛个异常返回 HTML前端写判断写到崩溃。所以我在项目一开始就强制统一响应体无论成功失败所有接口都返回同样的 JSON 结构{ code: 200, msg: success, data: {} }code 为 200 表示成功400 表示参数错误401 表示未登录500 表示服务器异常。data 根据接口不同可以是对象、列表或 null。前端 Axios 拦截器统一判断 code如果非 200 就直接弹出 msg 提示业务代码里就不需要每个请求都处理异常分支了。后端定义一个Result类提供Result.success(data)和Result.error(code, msg)静态方法。再加上一个RestControllerAdvice全局异常处理器把业务异常、参数异常、兜底异常统一转换成响应体输出。这样后端任何角落出问题前端拿到的都是规范结构而不是一条乱七八糟的报错文本。2.3 前端技术栈Vue3 Vite Pinia Element Plus前端选型比较直接。Vue3 是当前主力框架组合式 API 让组件逻辑复用更简单。构建工具用 Vite开发时热更新比 Webpack 快很多尤其页面一多Webpack 冷启动的等待时间很熬人。路由用 Vue Router 4状态管理用 PiniaUI 组件库用 Element Plus——后台管理界面基本都是表格、表单、弹窗Element Plus 直接拿来用能省一半工作量。初始化项目我用的是 Vite 官方模板。命令很简单装好依赖后把src拆成api、router、stores、views、components、utils六个目录。api 目录统一管理所有接口请求stores 放购物车和登录态views 按角色分 user 和 admin 两个子目录utils 放 Axios 实例和工具函数。这样一个结构对单人开发来说略显规整但好处是后面别人接手代码看目录就能猜到项目怎么组织。3. 数据库设计与核心表结构3.1 从业务流转推导表清单我设计数据库的习惯不是一上来就画 ER 图而是先模拟用户操作流程看数据从哪儿产生、往哪儿流动。把居民从注册到确认收货的完整路径走一遍自然能得出需要的表。居民打开系统先登录涉及用户表看到分类和商品列表涉及分类表和商品表把商品加进购物车涉及购物车表提交订单时生成订单主表和订单明细表管理员处理后订单状态直接放在订单主表里。一共六张表足够覆盖需求。后面如果要扩展套餐、库存批次、支付流水再往这个模型上叠加。这里有一个关键取舍订单为什么需要主表和明细表两张因为一张订单会包含多种商品如果把商品名称、数量、单价全部塞进订单表一列查询和汇总会非常痛苦。订单主表只放订单号、用户 ID、总金额、状态这类订单级信息订单明细表每行放一个购买项通过order_id关联回主表。这样“按订单查明细”和“按商品汇总销量”都非常方便。3.2 核心表字段设计把六张表的重点字段列出来直接看表名关键字段说明userid, username, password, phone, role, address角色区分居民和管理员categoryid, name, sort商品分类productid, category_id, name, price, stock, version, image, sale_statusversion 字段用于乐观锁cartid, user_id, product_id, quantity购物车表ordersid, order_no, user_id, total_amount, pay_status, order_status, address, remark订单主表order_itemid, order_id, product_id, product_name, product_image, price, quantity订单明细快照订单明细表里的product_name和product_image是一次深刻教训换来的。最初做电商类项目时明细表只存了product_id靠关联查询显示商品名称。后来商品被管理员下架改名历史订单里的商品名全变了用户看到的订单记录和当时买的东西对不上。从那以后凡是要保存历史快照的地方我都会把关键信息冗余到业务表里。宁可多几个字段也不能让历史数据随源数据漂移。3.3 库存字段为什么要带version商品表的stock是库存version字段是配合乐观锁用的。这个设计看起来多此一举但在并发下单场景下非常关键。这个购物场景的特点就是短时间内大量居民同时打开页面抢购米面油如果两个线程同时读到库存为 100、同时扣 5不加限制就会出现超卖。乐观锁的做法是扣库存的 UPDATE 带上version #{version}条件更新成功后 version 加 1。假设两个线程都读到version1第一个线程执行UPDATE product SET stockstock-5, version2 WHERE id? AND stock5 AND version1成功第二个线程同样执行时 version 已变成 2条件不满足更新行数为 0。业务层识别到行数为 0就知道扣减失败要么重新读库存重试要么提示用户库存不足。这个点在第 7 章会专门展开一次完整事故记录。3.4 套餐模式特殊时期的高效方案这类购物场景里还有一个共性问题居民大量购买的是固定的几类物资米、面、油、蔬菜包、鸡蛋。如果让居民一个个选下单慢、配货更慢。我的做法是支持“套餐商品”套餐本质上是商品表里的一条记录比如“基础生活套餐 A”价格是一个固定总额套餐里包含若干细项。订单明细表只记录套餐本身配货时后台按楼栋汇总套餐数量直接打包一次送过去。这个模式对配货效率的提升非常明显。最初居民自由选品时一单可能有十几样东西志愿者配货要到处找改成套餐制以后每天按套餐种类准备固定数量就行。实际演进中还可以建套餐明细表把套餐 ID 和包含的具体商品、数量存起来前端下单时可选套餐也可拆开选单品。这里要注意库存扣减必须落到套餐包含的底层商品上否则会出现套餐下单后底层商品库存没变的问题。4. 后端核心实现与关键代码4.1 配置与启动后端工程结构采用常规的三层架构controller、service、mapper。SpringBoot 启动类不需要额外配置SpringBootApplication扫描包路径下所有组件即可。数据库连接和 MyBatis 配置放在 application.yml 里关键配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/community_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case必须开它能将数据库里的order_no自动映射到实体类的orderNo省去大量 resultMap 配置。时区配置也建议显式写否则 MySQL 连接时区不正确系统时间会与本地差 8 小时。启动类所在包必须放在所有包的最外层不然 SpringBoot 默认扫描不到子包下的 Controller 和 Service。这是个新手容易踩的低级坑检查启动类位置比检查配置更优先。4.2 JWT登录和角色控制登录接口收到用户名和密码后先用BCryptPasswordEncoder校验密码通过后生成 JWT 字符串返回给前端。JWT 负载只放 userId 和 role 两个字段尽量保证 token 短小。密钥写在配置里而不是硬编码在代码中方便后续更换。String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();后端用一个拦截器做全局鉴权除/auth/login等白名单接口外其他请求必须携带有效 token。拦截器从请求头拿到 token解析出 userId 和 role放进请求上下文的 ThreadLocal 里后续接口直接从 ThreadLocal 取当前用户信息。这样做每个 Controller 方法都不需要重复写RequestHeader代码干净很多。角色控制放在拦截器里统一判断如果接口需要管理员权限而当前用户 role 不是管理员直接返回 401。没有引入 Spring Security 的注解权限体系原因还是那句话——这个项目角色少、规则简单用框架反而增加学习成本。4.3 下单事务一个接口的完整逻辑下单是系统最核心的接口也最容易出错。看代码骨架Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, String address, String remark, ListCartItemDTO cartItems) { Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setAddress(address); order.setRemark(remark); order.setPayStatus(0); order.setOrderStatus(0); orderMapper.insert(order); BigDecimal total BigDecimal.ZERO; for (CartItemDTO dto : cartItems) { Product product productMapper.selectById(dto.getProductId()); if (product null) { throw new BizException(商品不存在); } int rows productMapper.decreaseStock(product.getId(), dto.getQuantity(), product.getVersion()); if (rows 0) { throw new BizException(库存不足或商品已下架); } OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setProductImage(product.getImage()); item.setPrice(product.getPrice()); item.setQuantity(dto.getQuantity()); orderItemMapper.insert(item); total total.add(product.getPrice().multiply(BigDecimal.valueOf(dto.getQuantity()))); } order.setTotalAmount(total); orderMapper.updateTotalAmount(order.getId(), total); cartMapper.clearCart(userId); return order; }Transactional(rollbackFor Exception.class)是必须写的。Spring 默认只回滚运行时异常如果业务层抛出的是检查型异常不加 rollbackFor 事务不会回滚订单创建一半、库存扣了却不报错这是灾难级别的问题。我习惯一律加rollbackFor Exception.class保证任何异常都回滚。扣库存用的是UPDATE ... WHERE id? AND stock? AND version?它同时做了两件事检查库存是否充足、保证并发安全。如果更新行数为 0说明库存被抢光或版本号变了直接抛异常触发事务回滚。初学者常见的问题是先select判断库存再update这两个操作之间没有原子性并发下必然超卖。订单明细里的product_name和product_image是从商品表冗余进来的这就是“快照思维”。用户下单那一刻买的是什么订单里就要永远保留当时的信息。商品之后改名、下架、换图都不能影响已经生成的订单。4.4 超时订单自动取消疫情期间很多订单其实是货到付款或人工结算用户可以选择“下单不支付”也就是pay_status0的状态。但如果订单不限制有效时间会有一堆僵尸订单占着库存。我的处理是下单 30 分钟后如果用户还没支付或确认系统自动把订单改成取消状态并回滚库存。实现用 SpringBoot 自带的Scheduled定时任务每 1 分钟扫描一次Scheduled(cron 0 */1 * * * ?) public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListOrder list orderMapper.listTimeoutOrders(deadline); for (Order order : list) { orderMapper.updateStatus(order.getId(), ORDER_STATUS_CANCEL); ListOrderItem items orderItemMapper.listByOrderId(order.getId()); for (OrderItem item : items) { productMapper.recoverStock(item.getProductId(), item.getQuantity()); } } }这个定时任务在主项目里单进程跑没问题。以后如果部署多实例就要考虑用分布式锁比如 Redisson防止两个实例同时把同一笔超时订单取消两次。小区购物系统的规模一般走不到这一步但提前了解这个风险是值得的。定时任务里尽量不要用固定的分钟参数实际业务需要在配置文件里可调不然每次改超时时间都得改代码重新打包。4.5 管理端统计报表统计接口的目的是让管理员每天打开后台就能看到“今天该备多少货、哪类商品需要多准备”。我做了三个接口今日订单数与金额、各分类销量排行、热销商品 TOP10。后面两个本质可以合在一起写一个多表关联查询但为了前端展示方便我拆成了两个。热销商品 TOP10 的 SQL 大致是SELECT oi.product_name, SUM(oi.quantity) AS total_quantity FROM order_item oi JOIN orders o ON oi.order_id o.id WHERE o.order_status ! 4 AND o.create_time #{startTime} GROUP BY oi.product_name ORDER BY total_quantity DESC LIMIT 10这类统计 SQL 用 MyBatis XML 写起来非常直观只需要调整时间参数就能看任意时段的销量。数据量在小几百单级别完全没有性能压力如果未来单量达到万级建议给orders.create_time和order_item.order_id都加索引不然联表统计会慢慢变慢。5. 前端页面实现与交互细节5.1 Vite初始化与基础依赖前端用 Vite 初始化项目命令如下npm create vitelatest community-shop -- --template vue cd community-shop npm install npm install vue-router4 pinia axios element-plusvue-router负责路由跳转pinia负责购物车和登录状态管理axios负责发请求element-plus提供现成的表格、表单、弹窗组件。安装完成后在src/utils/request.js里封装 Axios 实例const instance axios.create({ baseURL: /api, timeout: 10000 }) instance.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) instance.interceptors.response.use(res { if (res.data.code ! 200) { ElMessage.error(res.data.msg) return Promise.reject(new Error(res.data.msg)) } return res.data.data })这个封装统一了所有接口的请求出口。任何接口返回错误都会自动弹出错误提示页面里不需要各自处理异常。接口定义在 api 目录下页面只调用异步函数不关心底层请求细节。这样写还有个好处以后如果要统一在请求头加设备信息、加版本号只需要改封装文件所有接口同时生效。5.2 商品列表与购物车的联动商品列表页的逻辑是进入页面时请求分类列表和商品列表顶部显示分类 tab点击分类切换商品。商品卡片展示名称、价格、库存、图片和“加入购物车”按钮。购物车状态管理我用了 Pinia。为什么要用 Pinia 而不是组件内部的ref因为购物车需要被导航栏、商品列表页、购物车页多个组件共享。如果每个页面各自维护一份数据用户点加购后切去另一个页面购物车就丢了。Pinia 作为全局 store天然解决跨组件数据同步问题。这里也提醒一下只有数据确实需要跨组件共享时才应该用全局状态管理不要为了用而用。购物车里存放的数据结构我保存的不只是productId和quantity还把名称、价格、图片一并放了进去。这样购物车页面展示时直接读本地数据不用为了渲染购物车再逐个请求商品详情。数据量小这种以空间换时间的做法在小型系统里很划算。5.3 路由守卫和角色菜单前端路由分两大部分居民端和管理端。居民端路由包括首页、商品列表、购物车、订单列表管理端路由包含商品管理、订单管理、统计看板。如果直接暴露管理端路径但没做权限控制普通用户输入 URL 就能进后台整个权限体系就白做了。我采用路由守卫处理router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role role ! to.meta.role) { next(/403) } else { next() } })每个管理端路由定义时通过meta.role标上管理员标识普通用户访问直接重定向到 403 页面。这样菜单显示和路由访问控制都有了依据。需要注意前端路由守卫只是用户体验层面的保障真正的安全边界必须靠后端的 JWT 拦截器不能信任前端跳转逻辑。5.4 管理台订单表格的状态流转管理端订单页面是最复杂的管理页面涉及四个状态流转待审核、配货中、配送中、已完成外加一个取消状态。Element Plus 的el-table绑定订单列表操作列根据order_status动态显示不同按钮。状态为待审核时显示“审核通过”按钮配货中显示“开始配送”配送中显示“确认完成”已取消或已完成则不显示操作按钮。点击按钮时调用对应状态更新接口成功后刷新当前页数据。这里有一个交互细节值得说每次状态变更后不要重新从第一页加载列表而是记录当前页码和每页条数刷新时仍然回到之前的页码。否则管理员在第三页操作完一条记录后整个页面跳回第一页体验很差。这个细节看似简单但很多初版后台都忽略了。6. 部署上线与联调经验6.1 本地联调Vite代理解决跨域开发阶段前端跑在 5173 端口后端跑在 8080 端口直接请求会产生跨域。最省事的办法是后端加CrossOrigin但这种方式在部署后还得再处理 Nginx 反向代理下的跨域。我的方案是前端 Vite 代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }所有请求路径以/api开头Vite 开发服务器自动转发到后端 8080。前端代码里 axios baseURL 设为/api后端 Controller 统一用/api作为接口前缀这样开发和生产环境不需要切换任何代码前端始终发相对路径请求。代理是本地联调时最简单的方案生产则交给 Nginx。6.2 生产部署jar包加Nginx后端打 jar 包用mvn clean package -DskipTests生成一个可执行 jar。服务器只需安装 JDK8执行nohup java -jar community-shop.jar 就能把后端跑起来。8080 端口只对内网开放外部请求统一由 Nginx 转发。前端npm run build后生成 dist 目录直接放到 Nginx 的静态目录。Nginx 配置需要注意两个关键点一是/api开头的请求反向代理到后端 8080二是静态资源用try_files支持 Vue Router 的 history 模式。server { listen 80; server_name localhost; root /opt/community-shop/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这行的作用是当用户直接访问/orders/1这种前端路由时服务器找不到对应物理文件就把请求回退到 index.html交给前端路由处理。不加这行刷新页面就会 404。这是部署前后端分离项目时最容易踩的坑我第一次上线时就因为这个被折腾了一个下午。6.3 数据库初始化与日常备份数据库初始化用一段建库建表 SQL 脚本启动项目前先执行一遍。因为表数量少我没有用 Flyway 做版本化迁移直接手动管理 SQL。日常备份用mysqldump定时执行命令大致是mysqldump -uroot -p community_shop backup_$(date %Y%m%d).sql对小区购物系统来说数据量不会很大一天全量备份一次绰绰有余。关键是备份文件最好保留近一周避免磁盘紧张。恢复时执行mysql -uroot -p community_shop backup.sql几分钟就能完成。7. 实战排查与避坑指南7.1 库存超卖事故一次典型的并发翻车第一次开发时扣库存代码是先查询库存、再看是否够减、再更新单纯串行测试完全没问题。后来我把系统给几个同事同时演示十个人同时抢一个还剩 8 件的商品结果订单全部创建成功库存变成了负数。原因很清楚多个线程同时读到库存为 8都判断够减然后都执行更新谁也没拦住谁因为中间没有数据库层面的原子约束。修复方案就是把“判断库存够不够”和“扣减库存”合并成一条 UPDATE 语句UPDATE product SET stock stock - #{quantity}, version version 1 WHERE id #{id} AND stock #{quantity} AND version #{version}数据库在锁行时会检查条件不满足条件则更新 0 行。业务层根据rows0抛出“库存不足”异常。加了这条约束后再测并发抢购库存始终不会为负。我建议所有有库存概念的系统都参考这种写法比先查后改可靠得多。如果用 Redis 做缓存库存那还要考虑缓存与数据库的一致性问题这是另一个复杂的课题小规模项目不推荐过早引入。7.2 MyBatis动态SQL的坑整数判断空字符串写商品列表的筛选 SQL 时有一个sale_status字段类型是 Integer值为 0 表示下架、1 表示上架。我在 XML 里写了这样的判断if testsaleStatus ! null and saleStatus ! AND sale_status #{saleStatus} /if运行时发现一个问题当saleStatus为 0 时条件不生效商品筛选结果不对。原因是 MyBatis 的 OGNL 在比较Integer和空字符串时做了特殊处理0 ! 这个表达式的结果在不同版本下有歧义很多情况下不会走这个分支。正确的写法是只判断 nullif testsaleStatus ! null AND sale_status #{saleStatus} /ifInteger 类型的参数判断统一只写 null 判断绝不写空字符串比较。String 类型才需要考虑空字符串情况。这个坑非常隐蔽如果测试时没人传 0可能上线很久都不会暴露。7.3 事务不生效的三种场景事务不生效是 Spring 开发里最折磨人的问题之一。我在开发过程中遇到过三种典型情况。第一种是方法内部调用。同一个 Service 里方法 A 调用方法 BB 上面标了Transactional但实际执行时事务没有包住 B。因为 Spring 事务依赖代理对象同类内部this调用不会经过代理注解自然失效。解决办法是把需要事务的方法拆到另一个 Bean 里或者让 A 也标上事务保证整个调用链都在事务内。第二种是异常被吞掉。方法明明标了事务但业务代码里 try-catch 把异常捕获了事务管理器感知不到异常自然不回滚。遇到这种情况建议 catch 里至少抛一个 RuntimeException让上层能看到错误。第三种是 rollbackFor 没设置。4.3 节已经提过如果方法抛的是自定义检查型异常Transactional默认不会回滚。我的习惯是统一加rollbackFor Exception.class少踩很多坑。7.4 Vue3响应式丢失的两个场景前端开发中我也踩过两个响应式的坑。第一个是用解构赋值拿 reactive 对象里的属性然后直接操作这个变量结果页面不更新。比如const state reactive({ cartCount: 0 }) let { cartCount } state cartCount 5 // 页面没有变化因为解构出来的cartCount是一个普通数字已经和 state 没有绑定关系。遇到这种情况应该用computed或storeToRefs获取响应式引用。第二个坑是直接替换数组。用ref声明数组时如果执行arr newArr而不是arr.value newArr同样不会触发更新。Vue3 的写法很直观但必须记住ref包裹的数据必须通过.value才能访问和赋值。对于已经上了 Pinia 的项目如果 store 里需要用解构而且要保留响应式用storeToRefs(store)就行。7.5 分页查询性能优化初期所有列表分页都是LIMIT offset, size。订单量到几百条时感觉不明显但把模拟数据跑到上万条时深分页的接口响应时间明显变长。原因是 MySQL 执行LIMIT 100000, 20时需要先扫描并丢弃前 10 万行IO 开销很大。针对分页查询可以采用基于游标的优化记住上一页最后一条记录的主键下一页用WHERE id ? ORDER BY id LIMIT ?查询。这个方案对主键连续有序的单表非常有效。对于带筛选条件的列表可以在筛选列上加复合索引配合游标分页能显著提高深分页性能。小区购物系统的订单列表也许到不了这个量级但知道了这个优化思路以后做更大项目就不会手足无措。8. 运行效果与扩展方向8.1 系统实测的表现项目做完后我在本地用真实数据做了压力测试和流程验证。用脚本模拟 50 人同时在线下单创建 100 个初始库存的商品并发下单 300 次最终库存始终没有出现负数也没有出现重复扣款事务回滚全部正常。接口层面 P95 响应时间在几十毫秒以内对一个小区购物场景来说完全够用。功能流程方面我按三种角色从头到尾走了一遍居民注册登录、浏览套餐、加入购物车、下单管理员审核订单、看配货清单、标记配送居民确认收货后订单状态置为已完成。整条链路走得通订单生命周期是健康的。8.2 值得做的扩展方向这个系统如果继续迭代我会优先考虑三件事。第一是真正的在线支付。目前支付状态是模拟的接微信支付或支付宝需要商户资质但接口预留了pay_status和pay_time接入网关时只需要新增一个支付回调接口改动不算大。支付回调要重点考虑幂等性否则重复回调会导致订单状态错乱。第二是小区维度。如果系统要覆盖多个小区就要在用户表、订单表里增加community_id字段让管理员只能看到自己小区的数据统计报表也要按小区隔离。这个演进方向在数据库设计初期就应该预留字段否则后面改造成本很高。第三是移动端。前后端分离给移动端接入留了后路接口不变只需要做小程序或 App 端。如果只想快速上线给前端加一个移动端适配样式也能应急使用。消息通知也可以接着做用短信或小程序订阅消息提醒用户订单状态变化能明显减少居民反复查询后台的频次。这个项目真正让我学到东西的反而不是框架用法而是怎么把现实世界的人工流程抽象成数据流。一个小区的买菜问题拆到最后就是角色、状态、库存、订单四个词的循环。遇到类似的业务比如社区团购、班级物资统计、公司下午茶拼单这套模型稍加改动都能复用。如果你也正在做这类项目建议先花点时间把业务链条理清楚再写代码数据库表设计想清楚了后面开发会顺畅很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑