
苍穹外卖推进到 day07我终于从管理端的一堆表格里抬起头开始碰用户真正点外卖时走的那条链路加购物车、填地址、提交订单。前面六天基本都在跟分类、菜品、员工登录这些东西打交道屏幕里全是运营后台的界面到了第七天画风突变小程序端开始介入核心任务变成“让用户能顺利下出一单”。这篇文章不准备按课程录屏重新复述而是把我当天写代码、联调、改 bug 的过程整理成一份可复现的开发手记。对正在跟这套项目或者自己搭一个外卖/电商小程序后端的人来说里面每个模块的取舍和踩坑应该都值得参考。1. 先把 day07 要交付的闭环拆清楚从“能看菜单”到“能下单”缺了三块积木1.1 前六天留下的“家底”到 day07 之前整个苍穹外卖项目已经有了一个比较完整的管理端底座员工登录和状态管理、分类维护、菜品的新增修改以及起售停售、文件上传。这些功能全部跑在管理后台给小程序的用户端提供的仅仅是一份可浏览的菜单数据。但是数据能用不代表业务闭环能跑通。用户在微信小程序里翻完菜单接下来要干的事情非常固定把菜加进购物车选一个收货地址然后提交订单。这三步缺任何一环这个项目就仍然只能算“后台管理系统”不能算“外卖系统”。所以 day07 的工作目标不是新增某个孤立功能而是要接通一条完整链路。我把它拆成了三块积木购物车、地址簿、订单提交。先说明一点支付、商家接单、派送、历史订单这些模块不在 day07 范围里那属于后面几天的任务。如果一次把链路铺得太长联调时出了问题很难定位。1.2 三块积木的依赖关系和开发顺序开发顺序上我的习惯是先做购物车和地址簿再做订单提交。原因很简单提交订单这个方法里要同时读取购物车数据和地址数据如果前面两个模块没定稿订单接口写一半就要返工。购物车解决的是“用户想买什么、买多少”的问题地址簿解决的是“送到哪、送给谁”的问题订单模块则是把这两个信息汇总落成一条订单主记录和一堆订单明细。从表结构上看订单表和购物车没有直接关系购物车只是下单过程中的临时容器订单明细才是订单真正的组成部分。开发时还有一个小要点购买按钮不一定要走购物车很多电商系统支持“立即购买”那是一条独立的单商品下单链路。但苍穹外卖的小程序端主路径是“菜单 → 购物车 → 结算”所以 day07 我先把购物车下单这条主路径彻底打通立即购买留给后续扩展避免一次开发两套类似逻辑。1.3 先把三张核心表立住下单链路涉及三张核心表地址簿表、订单主表、订单明细表。我在动手写代码之前先把表结构定好不然写完接口再改表连带 Mapper、VO、Service 全要动一遍太痛。地址簿表按常规电商设计需要存收货人、电话、省市区和详细地址以及一个默认地址标记CREATE TABLE address_book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户id, consignee VARCHAR(50) NOT NULL COMMENT 收货人, sex VARCHAR(2) COMMENT 性别, phone VARCHAR(20) NOT NULL COMMENT 手机号, province_name VARCHAR(32) COMMENT 省名称, city_name VARCHAR(32) COMMENT 市名称, district_name VARCHAR(32) COMMENT 区名称, detail VARCHAR(200) COMMENT 详细地址, label VARCHAR(10) COMMENT 标签家/公司/学校等, is_default TINYINT NOT NULL DEFAULT 0 COMMENT 是否默认 0否 1是, create_time DATETIME, update_time DATETIME );订单主表我一般会在建表阶段就把状态字段设计完整宁多勿缺。day07 只用到“待付款”和“已取消”两种状态但接单、派送、已完成这些状态后面马上会用到不如一次性建齐CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, number VARCHAR(50) NOT NULL COMMENT 订单号, status TINYINT NOT NULL DEFAULT 1 COMMENT 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消, user_id BIGINT NOT NULL, address_book_id BIGINT NOT NULL, order_time DATETIME NOT NULL, checkout_time DATETIME COMMENT 支付时间, pay_method TINYINT COMMENT 支付方式 1微信 2支付宝, pay_status TINYINT COMMENT 0未支付 1已支付 2退款, amount DECIMAL(10,2) NOT NULL COMMENT 订单金额, remark VARCHAR(100) COMMENT 备注, consignee VARCHAR(50), phone VARCHAR(20), address VARCHAR(255), cancel_reason VARCHAR(255), rejection_reason VARCHAR(255), cancel_time DATETIME );订单明细表则需要冗余一份菜品快照。这里说的“冗余”不是贬义而是必须。用户下单之后商家后台的菜品可能改价、改名甚至下架但历史订单里的明细必须保持用户下单那一刻的样子所以明细表里要存菜名、图片、价格、口味这些字段不能下单时再去关联实时菜品表CREATE TABLE order_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT, setmeal_id BIGINT, name VARCHAR(50) NOT NULL COMMENT 菜品/套餐名称快照, image VARCHAR(255) COMMENT 图片快照, dish_flavor VARCHAR(50) COMMENT 口味快照, number INT NOT NULL COMMENT 数量, amount DECIMAL(10,2) NOT NULL COMMENT 单价快照 );2. 购物车模块我为什么没用数据库表Redis hash 才是这个场景的顺手工具2.1 数据库表能存购物车但不是最优解很多人看到购物车第一反应是建一张shopping_cart表字段无非是user_id、dish_id、number、create_time。这个设计没问题很多小型管理系统也这么干。但放到苍穹外卖这个场景里有几个点让我决定用 Redis。第一购物车是典型的高频读写、弱一致性数据。用户每点一次“加入购物车”就是一次写每打开一次购物车列表就是一次读。这种数据放 MySQL 也不是扛不住但完全没有必要占用数据库连接。第二购物车几乎没有跨端同步需求用户在小程序加购不太可能在另一个端看到同样的购物车所以不需要强持久化。第三Redis 本身提供 hash 结构天然适合按用户维度去组织数据一个 key 就是某个用户的整个购物车。当然Redis 方案也有前提购物车数据允许丢失或者丢失后用户重加即可。外卖场景完全满足这个前提。如果你做的是那种购物车数据必须长期保存、跨设备同步的电商系统那还是老老实实落库。2.2 Key 与 field 设计口味必须进标识Redis 里存放购物车时我采用的方案是keycart:{userId}每个用户一个 hashfield{dishId}_{dishFlavor}同一菜品不同口味拆成不同 fieldvalue序列化成 JSON 的购物车条目对象包含菜品 id、名称、图片、口味、单价、数量、加入时间这个 field 设计是当天踩坑踩回来的。最初我图省事field 只放dishId结果同一种菜点了“微辣”和“中辣”两个口味后加的覆盖了先加的数量也对不上。后来改成dishId 口味作为 field才把不同规格的同一菜品区分开。至于为什么不用skuId或者某个统一商品编码是因为外卖项目的菜品表里没有独立的规格表口味只是一个字符串字段所以用拼接方式最省事。加入购物车时如果 field 已存在就读取 JSON 把数量加一如果不存在就新构造一个 JSON 放进去。购物车条目的 JSON 大致长这样{ dishId: 101, dishName: 鱼香肉丝, dishFlavor: 微辣, number: 2, amount: 28.00, image: /img/dish/101.jpg, createTime: 1720000000000 }2.3 四个接口的落地细节购物车模块一共四个接口加入购物车、查询购物车、修改数量、清空购物车。核心代码都不长但有几个细节一定要注意。加入购物车时先从登录态里拿到userId拼出cart:{userId}这个 key然后用BoundHashOperations操作它。关键是 field 一定要用dishId _ dishFlavor去拼不能只拼菜品 id。判断 hash 里有没有这个 field有就数量加 1没有就新建条目写入String field dish.getId() _ dishFlavor; BoundHashOperationsString, String, String hashOps stringRedisTemplate.boundHashOps(CART_KEY_PREFIX userId); if (Boolean.TRUE.equals(hashOps.hasKey(field))) { CartItem cartItem JSON.parseObject(hashOps.get(field), CartItem.class); cartItem.setNumber(cartItem.getNumber() 1); hashOps.put(field, JSON.toJSONString(cartItem)); } else { CartItem cartItem new CartItem(); cartItem.setDishId(dish.getId()); cartItem.setDishName(dish.getName()); cartItem.setDishFlavor(dishFlavor); cartItem.setNumber(1); cartItem.setAmount(dish.getPrice()); cartItem.setImage(dish.getImage()); cartItem.setCreateTime(System.currentTimeMillis()); hashOps.put(field, JSON.toJSONString(cartItem)); }查询购物车就简单了hashOps.entries()拿到全部条目反序列化后按createTime升序排一下保证用户看到的是“先加的在前”。修改数量时除了直接更新 number 字段要特别注意边界如果前端传过来的数量小于等于 0说明用户想删掉这条这时应该把 field 直接删除而不是把 number 改成 0 留在购物车里。清空购物车最省事直接stringRedisTemplate.delete(cart: userId)。我额外做了一步订单提交成功后自动清空购物车。这个动作放在订单事务内部还是外部要仔细想清楚下面讲到提交订单时会展开。3. 地址簿模块CRUD 不难难在 is_default 这张牌怎么打3.1 字段设计里最值得说的一处冗余地址簿模块在苍穹外卖里是标准 CRUD新增地址、修改地址、查询列表、查询单个、删除地址、设置默认地址。看着简单写的时候问题不少。最值得说的是省市区字段。地址簿表里我没有只存一个“省市区编码”而是把province_name、city_name、district_name全都冗余出来了。理由和订单明细冗余菜品快照一样用户下单后在订单列表里要展示收货地址如果只存编码每次都要调地区表去翻译费时又费事。在这种体量的系统里直接存名称是最省心的做法。手机号字段我用的是VARCHAR(20)而不是VARCHAR(11)。虽然国内手机号固定 11 位但地址簿里可能填座机、400 电话或者未来开放海外手机号所以给点余量不会有坏处。3.2 默认地址唯一性三种实现我选了先清后置地址簿最麻烦的业务规则只有一个“每个用户最多只有一个默认地址”。实现方式我考虑过三种。第一种是应用层先查再改先查一下这个用户有没有默认地址如果已经有默认地址且不是当前这条就把它改成非默认再把当前这条设成默认。这种写法在并发下容易出问题两个请求同时查都以为没有默认地址结果插了两条默认。第二种是数据库唯一索引加一个user_id is_default的联合唯一索引但这条路走不通因为表里每个用户会有多条is_default0的记录唯一索引会限制只能有一条非默认记录直接违背业务模型。第三种是我最终采用的“先清后置”设置默认地址时先用一条 update 把这个用户所有地址的is_default全部置 0再把指定地址的is_default置 1。两步包在同一个事务里天然规避并发问题。Transactional public void setDefault(Long addressBookId, Long userId) { // 第一步把该用户所有地址的默认标记清掉 addressBookMapper.updateByUserId(userId, 0); // 第二步把指定地址置为默认 addressBookMapper.updateDefault(addressBookId, 1); }对应的 SQL 长这样UPDATE address_book SET is_default 0 WHERE user_id #{userId}; UPDATE address_book SET is_default 1 WHERE id #{addressBookId};3.3 删除默认地址的连带处理删除地址时也藏着一个坑如果删掉的正好是默认地址那么用户下次下单时系统去查默认地址会查不到前端就会提示“请先设置默认地址”。业务上说得通但体验不好。我当时的处理是删除前先判断这条地址是不是默认地址如果是删除后随手把该用户最近一条地址设为默认。如果用户一条地址都没有了那就不设置等下次新增地址时把第一条自动设为默认。新增地址时也顺手做了这个规则如果当前用户没有任何地址新增的第一条自动设成默认地址。这段逻辑不复杂但很影响体验。很多项目上线后用户反馈“明明设置了地址下单时还是让我重新选”八成就是这里没处理干净。4. 提交订单事务边界、金额边界和订单号三个核心决策一次说清4.1 下单链路到底分了哪几步提交订单是 day07 的重头戏它不像前两个模块那么独立而是要把地址簿、购物车、订单表、订单明细表串起来。我梳理出的下单链路如下根据前端传来的收货地址 id查出完整地址信息校验地址是否存在且属于当前用户从 Redis 里取出当前用户的购物车数据如果购物车为空直接拦截后端重新计算订单总金额不信任前端传来的金额构造订单主记录状态置为“待付款”插入订单主表拿到自增订单 id遍历购物车条目构造订单明细批量插入明细表清空 Redis 购物车返回订单 id、订单号、订单金额给前端由前端发起后续支付这个顺序是我反复调整过的。最容易踩的坑是第 6 步和第 7 步的顺序。有人喜欢先清购物车再插订单一旦插订单失败购物车已经空了用户还得重新加一遍非常恼火。所以我坚持“先落订单再清购物车”并且把这两件事放在同一个事务里要么都成功要么都失败。4.2 订单号别用一长串随机数双段拼接更好排查订单号的设计我见过不少项目直接UUID.randomUUID().toString()然后去掉横杠搞出一串 32 位谁也记不住的大字符串。倒也不是不能用但日志排查、客服沟通、用户复制订单号时都很难受。我采用的是“时间戳 用户 id 后四位 随机数”的双段拼接方式private String generateOrderNumber(Long userId) { String dateTime LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)); String userSuffix String.format(%04d, userId % 10000); int random ThreadLocalRandom.current().nextInt(1000, 9999); return dateTime userSuffix random; }这样生成的订单号有明确含义从订单号能看出下单时间能看出大致是哪个用户下的。即便并发量上来时间精确到秒、用户 id 四位、随机数四位碰撞概率已经非常低。对苍穹外卖这种体量完全够用。如果未来并发更高可以换成 RedisINCR生成自增序号或者直接依赖分布式 ID 组件。4.3 事务要放在“业务入口”上不要散落到每个 mapper下单涉及多张表写入必须加事务。但事务加在哪里很有讲究。我见过一种写法每个 mapper 方法上都标个Transactional看着每个操作都有事务实际根本没生效。因为 Spring 默认的事务传播行为是 REQUIRED多个 mapper 方法被同一个 Service 方法调用时它们会加入同一个事务。如果 Service 方法本身没有事务每个 mapper 方法各自开启一个短事务提交订单这个跨表操作就没有整体一致性——订单主表插入成功明细表插入失败主表那条数据也会被提交数据就残了。正确的做法是在 Service 的submitOrder方法上加Transactional让整个下单流程成为单一事务边界Transactional(rollbackFor Exception.class) public OrderSubmitVO submitOrder(OrderSubmitDTO dto, Long userId) { // 地址校验 // 购物车读取 // 金额重算 // 订单主表插入 // 订单明细批量插入 // 清空购物车 }这里还有一个细节rollbackFor必须显式指定为Exception.class不能只依赖默认值。因为 Spring 默认只在遇到 RuntimeException 时才回滚如果业务代码里抛出受检异常事务不会回滚。下单流程中的 Service 层我习惯统一抛自定义的运行时异常这样事务回滚才有保障。4.4 前端传来的 totalAmount我一行都没用这是一个必须坚持的底线订单金额以后端计算为准。前端传过来的totalAmount理论上应该等于购物车总额但它可以被篡改、可能因为前端精度问题算错也可能在客户端被恶意修改。所以我直接把 DTO 里的金额字段忽略全部用后端从 Redis 读出来的购物车单价和数量重算BigDecimal totalAmount BigDecimal.ZERO; for (CartItem cartItem : cartItems) { BigDecimal itemTotal cartItem.getAmount() .multiply(BigDecimal.valueOf(cartItem.getNumber())); totalAmount totalAmount.add(itemTotal); }所有金额计算都用BigDecimal而且用multiply和add方法运算不能因为图省事用double。外卖项目里金额精度问题一旦出 bug轻则订单金额差几分钱重则对账永远对不平。5. day07 联调阶段翻过的五个车每个都能在网上查半天5.1 购物车数量回显Long 直接塞 Integer 爆 500联调时第一个问题出现在购物车接口。前端拿到购物车列表后发现number字段变成了 Long 类型JS 里数值显示倒是没问题但在某些严格校验的组件里报类型错误。我排查后发现问题出在我用了hashOps.increment(field, 1)去累加数量这个方法的返回值是Long而CartItem.number是Integer反序列化时直接把数字塞进 Integer 字段短的没事一旦超过 2^31 就会出问题。虽然是低概率事件但类型严格的项目里绝不能在 Redis 计数和实体字段之间做隐式类型切换。我后来统一改成“读取 JSON → 修改 number → 写回 JSON”的方式number字段在 Java 里坚持用Integer序列化出来就是整数。5.2 同菜不同口味被合并成一条第二个问题就是我在第 2.2 节提到的field 只用了dishId点微辣再点中辣后加的口味直接把前一个覆盖了。这个 bug 不联调根本发现不了因为接口层面看两次“加入购物车”都返回成功但购物车列表的数量始终不对。解决后我把 field 的拼接逻辑封装成了一个独立方法private String buildCartField(Long dishId, String dishFlavor) { if (dishFlavor null || dishFlavor.isBlank()) { return String.valueOf(dishId); } return dishId _ dishFlavor; }这里还要注意有些菜品没有口味dishFlavor可能是空字符串拼接时要过滤否则同一个菜加一次是101加一次是101_又被拆成两条。5.3 事务被 try-catch 吞掉订单半截入库第三个问题非常典型也是新手最容易踩的下单时我在 Service 方法里加了 try-catch想着把异常捕获后转成业务提示返回给前端。结果看似下单失败时前端确实收到了“库存不足”的提示但数据库里订单主表已经插入了一条新纪录。原因很直白Transactional是靠异常触发回滚的我在 catch 块里把异常吞掉了Spring 根本感知不到异常发生事务自然不会回滚。正确的做法有两种一种是不在事务方法内部 catch让异常直接抛出去另一种是 catch 住之后记录日志然后重新抛一个 RuntimeException。我选的是后者因为日志里要保留原始堆栈方便排查try { // 下单逻辑 } catch (Exception e) { log.error(提交订单失败userId{}, userId, e); throw new BusinessException(提交订单失败请稍后重试); }这样既能把业务提示返回给前端又不会影响事务回滚。5.4 BigDecimal 判断金额边界时翻车第四个问题出现在金额判断上。购物车清单里有一种情况前端希望下单时预校验金额是否大于零我写成了if (totalAmount.equals(BigDecimal.ZERO)) { throw new BusinessException(订单金额异常); }结果一个单价 0.00 元的商品比如某些赠品活动直接下单失败。问题出在BigDecimal.equals不仅比较数值还比较精度。0和0.00在 equals 下就是两个对象BigDecimal.ZERO.equals(new BigDecimal(0.00))返回 false。这算是 BigDecimal 最经典的坑。凡是比较大小一律用compareTo不要用equalsif (totalAmount.compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(订单金额异常); }以后所有金额比较都照这个规矩来能少踩不少坑。5.5 默认地址删了下单还往旧地址塞第五个问题和地址簿的默认地址规则有关。联调时我反复测“删除默认地址后再下单”发现订单居然还能提交成功而且收货地址是已删除的那条。查了半天问题出在订单提交接口的地址查询上我只判断了addressBookId是否为空没判断这条地址是否已经被删除。后端从数据库里查出来的地址对象虽然字段都还在但记录状态已经是逻辑删除前端在下单时又把这个 id 传上来了。我的修复分了两层第一层提交订单前校验地址是否存在不存在直接抛异常第二层删除地址时如果删除的是默认地址自动把最近一条地址设为默认避免用户下次打开结算页时默认地址为空。这两条规则单独看都很简单连在一起才让整个地址模块真正闭环。5.6 顺手一个建议状态字段用枚举别让“1/2/3”裸奔最后分享一个后期维护时深有体会的建议。订单状态、支付状态、地址默认标记这类字段建议尽早定义成枚举类而不是在代码里到处写魔法数字。比如订单状态我定义了一个枚举public enum OrderStatus { PENDING_PAYMENT(1, 待付款), PENDING_ACCEPT(2, 待接单), ACCEPTED(3, 已接单), DELIVERING(4, 派送中), COMPLETED(5, 已完成), CANCELLED(6, 已取消); private final Integer code; private final String description; // 构造方法、getter 略 }用枚举的好处是第三天回头看代码时不用对着数字猜含义而且所有用到状态的地方都走同一个枚举不容易写错。day07 当天我只用到两个状态但把整个枚举建好后续接单、派送模块的新增就非常顺。做完这些用户端从“浏览菜单”到“提交订单”的主链路总算是真正跑通了。个人体会比较深的一点是这类业务型项目卡住你的往往不是某一个接口写不出来而是接口与接口之间的连接缝——购物车和订单之间的数据传递、地址和订单之间的依赖关系、事务边界内外到底该放哪些操作这些连接缝想清楚了写代码反而很快。如果让我把 day07 重做一遍我会先画一张下单的时序图再动手写表结构能省掉至少一半的返工时间。