写到Day08,苍穹外卖这个项目总算是摸到了整个外卖系统最核心的那条业务线:用户下单、支付、订单状态流转。前面几天把管理端的功能铺完了,菜品、套餐、分类、员工这些基础CRUD做得再熟,那也还是后台管理的一部分。Day08开始才是真正和前端的下单体验挂钩的时刻,购物车加菜、提交订单、调起微信支付、超时自动取消、催单提醒,这一整套闭环走通,项目才像是“活”了。
这篇复盘我尽量不写成流水账,重点放在我实际开发时踩过的坑、想明白的逻辑,以及为什么这么设计的思考上,方便后面做到这一天的同学少走点弯路。
1. Day08的业务范围与项目阶段定位
1.1 先看看项目已经走到了哪一步
苍穹外卖整个项目的经典技术栈组合是:Spring Boot + MyBatis + MySQL + Redis + 微信小程序。Day01到Day07基本把管理端业务吃透了,员工管理、分类管理、菜品管理、套餐管理、店铺营业状态,再加上AOP切面和Redis缓存的处理。这些功能背后的核心是“管理”二字,也就是商家侧怎么做数据维护。
Day08的转变很关键,它从管理端直接切到了用户端(C端)的核心交易链路。用户侧的动作大概有这么几个:浏览菜品、加入购物车、查看购物车、确认下单、选择地址、支付订单。整个流程跑完,对应的数据库表会新增订单主表(orders)、订单明细表(order_detail)、购物车表(shopping_cart),再加上之前可能已经有的地址簿表(address_book)。
1.2 Day08核心功能范围
按照常见的教程组织方式,Day08主要覆盖以下功能点:
- 用户端登录与信息获取:微信小程序端用户登录态的处理
- 购物车管理:添加菜品、删除购物车中某个商品、清空购物车、查看购物车列表
- 地址簿管理:用户维护收货地址,下单时选择
- 用户下单:根据购物车数据生成订单主表和明细表
- 微信支付:对接微信支付统一下单接口、处理支付回调
- 订单状态管理:待支付、已支付、配送中、已完成、已取消等状态流转
严格来说有些教程会把微信支付单独拆到Day09,但Day08如果只做到下单不接支付,订单流程就不闭环。我这里按“下单+支付+订单状态流转”一起做的方案来复盘,实际测试支付的时候用沙箱或者模拟支付最省时间。
1.3 技术侧重点变化
从这一天的任务开始,项目的重点从“增删改查”转向了“业务状态机”和“事务一致性”。这是很多初学者第一次真正感受到后端开发里“订单不能凭空消失”“金额不能算错”“库存不能乱扣”这几个约束。之前做的CRUD接口,失败了报个错也就完了,但订单业务一旦出错,用户的真金白银就没了。
所以这一天的学习和复盘中,我会重点盯住三样东西:数据一致性、接口幂等、状态流转的唯一性。后面每一块内容都会围绕这三条线展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 购物车模块设计:为什么用数据库而不用Redis直接存
2.1 表结构设计与字段含义
购物车这个模块看起来很简单,但是做细了还是有不少门道。先看表结构:
sql复制CREATE TABLE shopping_cart (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(32) COMMENT '商品名称',
user_id BIGINT NOT NULL COMMENT '用户ID',
dish_id BIGINT COMMENT '菜品ID',
setmeal_id BIGINT COMMENT '套餐ID',
dish_flavor VARCHAR(50) COMMENT '口味',
number INT NOT NULL DEFAULT 1 COMMENT '数量',
amount DECIMAL(10, 2) NOT NULL COMMENT '金额',
image VARCHAR(255) COMMENT '图片',
create_time DATETIME COMMENT '创建时间'
) COMMENT '购物车表';
你可能注意到了,这张表直接冗余了name、amount、image这些商品基本信息。为什么不直接只存dish_id,等查询的时候再去菜品表JOIN?因为购物车是用户频繁查看的数据,冗余字段可以减少查询时的关联次数。而且菜品修改名称或价格后,用户已经加购的条目也应该保持原样,这个设计算是业务上的取舍。
2.2 添加购物车的接口逻辑
添加购物车的核心逻辑是:判断之前是否已经加购过同样的商品,如果存在就数量加一,否则就新增一条记录。
这句“同样的商品”要仔细想清楚。对于菜品来说,除了dish_id,还要看口味。用户点了一份“宫保鸡丁”,一份微辣、一份不辣,这是两条不同的购物车记录,不能合并。所以重复判断的条件里必须带上dish_flavor。
java复制@Override
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
shoppingCart.setUserId(BaseContext.getCurrentId());
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
if (list != null && list.size() == 1) {
// 已经存在,数量加一
shoppingCart.setId(list.get(0).getId());
shoppingCartMapper.updateNumberById(shoppingCart);
} else {
// 不存在,插入新记录
Long dishId = shoppingCartDTO.getDishId();
if (dishId != null) {
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId());
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
}
}
这段代码里有一个容易被忽略的坑:ShoppingCartDTO里传入的可能只有dishId或者setmealId,一个加购请求只会有一个类型,判断的时候要优先看清楚。如果两个都为空,说明前端请求参数有问题,该直接抛异常。
2.3 清空购物车的时机与并发问题
清空购物车有两个场景:一是用户主动删除购物车中的所有商品,二是下单成功之后自动清空。下单后必须清空,否则用户下完单回来看购物车还是满满当当,这个体验就很奇怪。
清空操作本身简单,一条DELETE语句:
sql复制DELETE FROM shopping_cart WHERE user_id = #{userId}
但如果是下单成功之后清空,就要考虑事务问题了。下单操作涉及:查询购物车列表、生成订单主表、生成订单明细、清空购物车。这四个步骤只要有一个失败,所有动作都应该回滚。所以下单方法上必须加@Transactional,清空购物车只是整个事务里的一个步骤,不能单独拎出来做。
我在第一次做的时候把清空购物车写在Controller里,结果下单接口调用成功了,购物车却没清掉。后来才意识到这个问题:Controller不在事务管理范围内,清空操作自己是一个独立事务,成功了就提交,下单那边失败也不影响它。正确的做法是把清空购物车放进下单的Service方法里,和订单生成绑在同一个事务中。
2.4 查看购物车列表的排序问题
查看购物车时,列表顺序最好按照加入时间倒序排,这样用户看到的是最近加购的内容。如果项目里没有明确要求,我一般会建议SQL里加一个ORDER BY create_time DESC。别小看这个细节,用户购物车一旦东西多了,顺序乱了,使用体验直接断崖式下降。
3. 下单核心链路:事务、状态机与库存扣减
3.1 下单前置校验:地址、购物车、营业状态
下单接口的第一步不是生成订单,而是做前置校验。校验项一般有三个:
- 地址簿是否存在且为当前用户的地址,防止用户传别人的地址ID
- 购物车是否有商品,没加购就直接下单,这种请求一定是异常请求
- 店铺是否处于营业中,休息时间下单要给用户明确提示
我在最初实现时只校验了地址和购物车,忽略了营业状态。后来测试发现,门店已经打烊了,用户还能正常下单,这在真实业务里是不允许的。最后在订单Service里加了店铺营业状态查询:
java复制Integer status = shopMapper.getStatus();
if (status == null || status == 0) {
throw new OrderBusinessException(MessageConstant.SHOP_NOT_OPEN);
}
注意营业状态的查询不能走Redis缓存里的那份,要查数据库实时状态。否则用户下单时拿到的是缓存里的旧值,容易出问题。
3.2 订单号生成策略:为什么不用数据库自增ID
订单表的ID如果用自增主键,直接暴露给前端,用户就可以通过订单号猜测到平台的订单总量,这在商业上是敏感的。所以项目里用的是业务订单号,通常取当前时间加上随机数或者用户ID拼接。苍穹外卖里常见的生成方式类似这样:
java复制String orderNumber = String.valueOf(System.currentTimeMillis())
+ String.valueOf(userId);
这种生成方式够用,但要做并发控制。同一毫秒内同一个用户下了两单,生成的订单号可能一样的。改进方案可以在这里加上一个随机种子或者自增序列。我实际的写法是用了UUID去掉横线再截取,反而更稳妥,格式上也不会太难接受。
3.3 主表与明细表的数据一致性
订单模块有三个核心表:orders(订单主表)、order_detail(订单明细表)、address_book(地址簿,只读)。下单时要把地址簿中的信息快照到主表中,包括收货人姓名、电话、详细地址,同时把购物车中的菜品数据快照到明细表中,包括菜品名称、口味、图片、数量、金额。
这里就要说“快照”的重要性了。菜品价格随时可能变,地址信息也可能被用户修改,如果订单表里不存这些快照,等商家要履约的时候再去查菜品表和地址簿表,拿到的可能是已经变过的数据。所以下单时该冗余的信息一定要冗余,不能偷懒只存ID。
下单Service的核心逻辑简化后是这样的:
java复制@Transactional
public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {
// 1. 校验地址
AddressBook address = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
if (address == null) {
throw new AddressBookBusinessException(MessageConstant.ADDRESS_NOT_FOUND);
}
// 2. 校验购物车
Long userId = BaseContext.getCurrentId();
ShoppingCart shoppingCart = new ShoppingCart();
shoppingCart.setUserId(userId);
List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
if (shoppingCartList == null || shoppingCartList.isEmpty()) {
throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
}
// 3. 构建订单主表
Orders orders = new Orders();
BeanUtils.copyProperties(ordersSubmitDTO, orders);
orders.setOrderTime(LocalDateTime.now());
orders.setPayStatus(Orders.UN_PAID);
orders.setStatus(Orders.PENDING_PAYMENT);
orders.setNumber(String.valueOf(System.currentTimeMillis()));
orders.setPhone(address.getPhone());
orders.setConsignee(address.getConsignee());
orders.setAddress(address.getDetail());
orders.setUserId(userId);
ordersMapper.insert(orders);
// 4. 构建订单明细表
List<OrderDetail> orderDetailList = new ArrayList<>();
for (ShoppingCart cart : shoppingCartList) {
OrderDetail orderDetail = new OrderDetail();
BeanUtils.copyProperties(cart, orderDetail);
orderDetail.setOrderId(orders.getId());
orderDetailList.add(orderDetail);
}
orderDetailMapper.insertBatch(orderDetailList);
// 5. 清空购物车
shoppingCartMapper.cleanByUserId(userId);
// 6. 组装返回结果
OrderSubmitVO orderSubmitVO = OrderSubmitVO.builder()
.id(orders.getId())
.orderNumber(orders.getNumber())
.orderAmount(orders.getAmount())
.orderTime(orders.getOrderTime())
.build();
return orderSubmitVO;
}
3.4 金额计算谁来算:前端还是后端
下单时请求里会带一个amount(订单总金额),但这个字段绝对不能直接信任。前端传一个1分钱的订单下去,后端如果把订单创建了,商家就会收到一笔离谱的履约任务。所以金额必须后端根据购物车重新计算。
最稳妥的计算方式是:循环购物车列表,累加amount * number得到总价,加总后如果和前端提交的金额不一致,直接抛异常。这个校验在正式项目中很常见。
有时候因为优惠活动、满减等原因,前端展示的金额和后端算出来的金额会出现差异,这种场景需要引入一套独立的计价服务。苍穹外卖项目里没有复杂的计价规则,所以后端按购物车商品单价乘以数量重新算一遍就够用。
3.5 事务控制的细节:回滚日志怎么看
下单方法加了@Transactional之后,如果中途抛出异常,Spring会回滚所有已执行的SQL。但有一个容易踩的坑:如果在Service里手动catch了异常,事务是不会自动回滚的。因为Spring的声明式事务默认只对RuntimeException和Error回滚,如果你在方法内部把异常吞掉了,事务提交时看不到异常,就会正常提交。
排查事务是否真正回滚,最直接的方式是看数据库里有没有生成脏数据,或者开启MyBatis的SQL日志,观察异常抛出后是否有ROLLBACK日志输出。
3.6 库存扣减:Day08该不该做
严格来说,真正的外卖平台在下单时还要做库存扣减和锁库存,但苍穹外卖的菜品(尤其是菜品和套餐)没有独立库存字段,所以Day08通常不做库存扣减。这个设计要明白:不是不需要,是业务上还没到这个复杂度。如果要在项目里扩展,可以在下单时对套餐库存表进行扣减,同时用Redis的Lua脚本保证原子性。
4. 微信支付接入:参数、签名与回调验证
4.1 支付流程的整体时序
微信支付的时序大致如下:
- 后端调用微信支付的统一下单接口,传入商户号、订单号、金额、回调地址等参数
- 微信支付返回一个预支付交易会话标识(
prepay_id) - 后端把
prepay_id和签名等相关参数返回给小程序端 - 小程序端通过
wx.requestPayment拉起收银台 - 用户支付成功,微信服务器异步通知后端的回调接口
- 后端收到回调,校验签名和订单金额,更新订单状态
4.2 统一下单参数组装
苍穹外卖项目里使用的是微信支付V2版本的API,通过HttpClient发送POST请求。这类接口需要按字典序拼接参数、生成MD5签名,再把参数转成XML格式发送。
java复制private String getPayUrl(String orderNumber, String amount) {
// 构建请求参数
SortedMap<String, String> parameters = new TreeMap<>();
parameters.put("appid", appId);
parameters.put("mch_id", mchId);
parameters.put("nonce_str", WxPayUtil.generateNonceStr());
parameters.put("body", "苍穹外卖订单");
parameters.put("out_trade_no", orderNumber);
parameters.put("total_fee", amount); // 单位是分
parameters.put("spbill_create_ip", "127.0.0.1");
parameters.put("notify_url", notifyUrl);
parameters.put("trade_type", "JSAPI");
parameters.put("openid", openId);
// 生成签名
String sign = WxPayUtil.getSignature(parameters, apiKey);
parameters.put("sign", sign);
// 转XML并发送请求
String xml = WxPayUtil.toXml(parameters);
// 发送POST请求,解析返回结果
}
这里好几个细节要特别强调。
第一个是金额单位。微信支付接口里所有金额单位都是“分”,不是“元”。数据库里订单金额是DECIMAL(10,2),传参时必须乘以100转成整数,否则用户下单10元,实际支付1毛钱。我在第一次对接时就因为这个问题踩了坑,回调对账时怎么都对不上,排查了很久才发现是单位问题。
第二个是签名算法。微信支付V2的签名规则是:所有非空的参数按参数名ASCII码从小到大排序,拼成key1=value1&key2=value2...这样的字符串,最后拼接上商户API密钥,再做MD5。也就是说排序、拼接、加盐、哈希,这几步缺一不可。
4.3 支付回调处理:身份验证与幂等性
支付成功后微信服务器会向notify_url发送一个POST请求,内容也是XML,里面包含订单号、支付结果、微信支付订单号等。回调地址必须是公网能访问的地址,本地开发时没有内网穿透工具很难测到真实回调。
回调处理的逻辑核心有两步:
第一步是验签。收到的回调请求里带了一个sign字段,后端需要按同样的规则重新生成签名,对比是否一致。如果不一致,这个回调极可能是伪造的,必须拒绝处理。
第二步是核对金额。回调里携带的total_fee要和数据库中订单的实际金额比对,防止支付金额与订单金额不一致。这一点很容易被忽略,但真正的支付系统必须做。
验证通过后更新订单状态,把订单状态从“待支付”改成“已支付”,再通知商户端接单。
这里还要考虑幂等性。微信服务器为了确保回调送达,会多次重试,同一个支付成功通知可能到达两次以上。如果后端不做幂等处理,第二次回调时就会重复更新订单状态。最简单的方式是更新前先查询订单当前状态,已经是“已支付”就直接返回成功。
4.4 测试环境下的模拟支付方案
真实环境调试微信支付很麻烦,需要营业执照、商户号、备案域名等一堆资质。我实测下来最省事的方案是:在后端预留一个模拟支付接口,开发环境直接调用它把订单状态改成已支付,同时跳过微信API调用。
java复制@PostMapping("/pay/mock")
public Result mockPay(@RequestParam Long orderId) {
Orders orders = new Orders();
orders.setId(orderId);
orders.setStatus(Orders.PAID);
orders.setPayStatus(Orders.PAID);
orders.setPayMethod(Orders.ON_LINE);
orders.setPayTime(LocalDateTime.now());
ordersMapper.update(orders);
return Result.success();
}
这样做的好处是:本地开发可以把整个订单流程完整跑通,不用依赖外部环境。等真机调试时再换成真实支付逻辑。教学项目里常见的方式是在Controller里做一个if判断,开发环境走模拟支付,生产环境走真实支付。我一般会加一个@Profile("dev")注解来控制,只让模拟支付接口在开发环境生效。
5. 超时未支付取消与客户催单
5.1 定时任务扫描超时订单
用户下单后如果一直不支付,订单会一直停在“待支付”状态,占着商家的运力资源。所以需要一个定时任务,把超过一定时间(一般是15分钟)未支付的订单自动取消。
苍穹外卖里用的是Spring自带的@Scheduled注解,配合@EnableScheduling启用定时任务。定时任务的逻辑是每隔一分钟扫描一次订单表,找出状态为“待支付”且下单时间超过15分钟的订单,更新为“已取消”。
java复制@Component
public class OrderTask {
@Autowired
private OrderMapper orderMapper;
@Scheduled(cron = "0 * * * * ? ")
public void processTimeoutOrder() {
LocalDateTime time = LocalDateTime.now().minusMinutes(15);
List<Orders> ordersList = orderMapper.getByStatusAndOrdertimeLT(Orders.PENDING_PAYMENT, time);
if (ordersList != null && ordersList.size() > 0) {
for (Orders orders : ordersList) {
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason("订单超时,自动取消");
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}
}
}
}
这个cron表达式0 * * * * ?表示每秒的第0秒执行一次,也就是每分钟执行一次。如果订单量大,可以改成0 0/5 * * * ?每5分钟执行一次。扫描频率越高,订单取消的及时性越好,但数据库压力也越大。
5.2 取消订单时要不要回退库存
苍穹外卖的菜品没有库存概念,所以超时取消订单直接改状态就行。但如果项目扩展了库存字段,取消订单时一定要把之前扣减的库存回退。这里要提醒的是:回退库存的操作要放在同一个事务里,否则可能出现订单状态已取消、库存没加回来的数据不一致情况。
5.3 客户催单:用轮询还是WebSocket
催单这个功能点,主要解决的问题是:用户下单后商家迟迟不接单,用户等得着急,点一下“催单”,商家端能立刻收到提醒。
实现方案有两种:轮询和WebSocket。苍穹外卖用的是Spring的WebSocket方案。商家管理端和服务器建立一个长连接,客户端点了催单接口后,服务端通过WebSocket向商家端推送一条消息。
java复制@Component
public class WebSocketServer {
private static final Map<String, Session> SESSION_MAP = new ConcurrentHashMap<>();
public void sendMessageToAll(String message) {
SESSION_MAP.values().forEach(session -> {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
这里要注意的是线程安全。Session的管理要用ConcurrentHashMap,不能直接用HashMap,否则多用户同时推送消息时可能出现并发问题。另外还要处理WebSocket连接断开时的清理工作,不然Session一直堆积,内存会爆掉。
催单功能本身不复杂,但它背后体现的是一个实时通讯的架构思想:客户端和服务端建立长连接、服务端主动推送消息、连接生命周期管理。后面如果扩展到“订单状态实时推送”“骑手位置实时更新”,思路是一样的。
6. 踩坑实录:这些坑我建议你先避开
6.1 BigDecimal精度问题
订单金额和明细金额用的都是DECIMAL(10,2),Java里对应的是BigDecimal。计算金额时不要用double或者float做运算,否则会出现0.1 + 0.2 = 0.30000000000000004这种经典问题。
java复制// 错误写法
double amount = 0.1 + 0.2;
// 正确写法
BigDecimal amount = new BigDecimal("0.1").add(new BigDecimal("0.2"));
还有一个隐藏的坑:数据库查出来的DECIMAL转成BigDecimal没问题,但前端传过来的金额是字符串,转换成BigDecimal时一定要用字符串构造器,不要用new BigDecimal(double)。 用new BigDecimal(0.1),打印出来是0.1000000000000000055511151231257827021181583404541015625,不是你以为的0.1。
6.2 微信回调地址配置问题
回调地址notify_url不能在浏览器里直接访问,它只能接收微信服务器发来的POST请求。本地开发时最常见的报错是:
- 本地没有公网IP,微信服务器根本访问不到你的回调地址
- 局域网IP地址也不管用,微信服务器在公网上,访问不了
192.168.x.x这种内网地址
解决办法是本地开发时用模拟支付跳过真实回调,等部署到服务器后再配置真实回调地址。不建议为了测试支付去折腾内网穿透工具,容易把精力浪费在环境搭建上。
6.3 事务方法内部自调用导致失效
@Transactional注解失效有一个非常隐蔽的场景:同一个类里的方法,如果A方法调用B方法,B上的@Transactional是失效的。原因是Spring的声明式事务是通过AOP代理实现的,自调用不会经过代理对象,所以B方法上的事务注解根本不会被解析。
解决方法是把需要事务的方法放到单独的Service类里,或者通过@Autowired注入自身代理对象来调用。我踩过这个坑之后,给自己定了一个规矩:所有写操作比较大的方法,尽量拆到独立的Service类里,保持事务边界清晰。
6.4 订单状态更新用条件UPDATE
更新订单状态时,不要直接UPDATE整行,容易把其他字段覆盖掉。而且最好带上条件限制,防止并发状态下重复更新:
sql复制UPDATE orders SET status = #{status}, cancel_time = #{cancelTime}
WHERE id = #{id} AND status = #{currentStatus}
带上AND status = #{currentStatus}的好处是:如果订单状态已经被别的线程改掉了,当前更新语句不会生效,影响行数为0,就能判断出当前操作是否是合法操作。这在支付回调处理、超时取消、用户取消这几个并发场景下尤其重要。
6.5 前端时间显示相差8小时
订单创建时间、支付时间在数据库里存的是LocalDateTime,但小程序端展示时经常发现时间少了8个小时。这不是你的代码写错了,而是时区问题。MySQL驱动连接配置里要加serverTimezone=Asia/Shanghai,同时在返回JSON格式时间时,建议统一格式化为字符串返回。
我是在做订单列表接口时发现这个问题的,前端拿到的时间怎么看怎么不对。排查到最后是数据源URL里没配时区,加上就正常了。
7. Day08之后还能怎么扩展
做完下单支付闭环之后,项目其实已经是一个完整的外卖了。但要是想拿这个项目去面试,或者做大作业,还可以在以下方向上加一些亮点:
- 订单状态的全链路进度展示:用户下单后能看到“商家接单→骑手取餐→配送中→已送达”的完整状态,这个需要配套的订单状态机
- 多个支付方式支持:微信支付之外可以扩展余额支付、优惠券抵扣,涉及到的就是账户体系和抵扣规则
- 订单统计报表:按天、按周统计营收,涉及到聚合查询和定时任务报表生成
- 高并发下单压力优化:用Redis做限流、用MQ异步化处理订单创建
不过这些扩展要建立在Day08的核心链路没有问题的基础上。我个人的建议是:先把下单、支付、订单状态流转吃透,保证任何情况下订单数据都不会出错,再考虑加复杂度。业务系统里,稳定压倒一切。
第一次做完支付闭环的那天晚上,我自己用测试账号下了一单,看到订单状态从“待支付”变成“已支付”,再变成“已取消”,虽然只是一个简单的状态数字在跳,但整套逻辑跑通的成就感还是很真实的。项目的乐趣就在这种不断打通链路的过程里。
