实战②:数据建模与 API 契约设计
上一篇把「要建什么」定死了,这一篇交付两样能拿去干活的东西:一张能跑的表和一份能照着写代码的接口说明书。听起来朴素,实际是全站最容易翻车的两步——字段类型选错,半年后财务对不上账;对象在各层之间不换乘,一次改表就打爆前端。这一篇每个决定都带上「错了会怎样」的现场,并且每一条都能亲手跑出来。
先把五个词一句话解释掉:
- DDL(建表语句):
CREATE TABLE那一整段话,告诉数据库「这张表有哪些列、每列什么类型、哪些值不许重复」 - 索引(index):一本字典前面的目录页。查得快了,但每次增删改都得同步维护目录,所以写会变慢
- DTO / Entity / VO:三种长得很像但使命不同的对象——DTO 收用户传来的请求,Entity 对应数据库一行,VO 是给前端看的响应
- 幂等(idempotent):同一件事做十次,结果和做一次完全一样。判断标准只有一条:重复执行有没有额外副作用
- 契约(contract):前后端共同签字的请求/响应说明书,规定字段名、类型、必填、出错给哪个码
同一批食材,三种样子。 你去菜市场买「西红柿炒蛋」,摊主给你的是采购单(带泥的西红柿、整盒鸡蛋、还附一张价签)——这就是 DTO:按外面的规矩收进来。进后厨后它们被洗好切配、装进标了菜名的备料盒——这就是 Entity:按仓库的格子归位,多了批次号和保质期。出菜时端上桌的是一盘成品,看不见泥土也看不见价签——这就是 VO:只把该给人看的样子端出去。很多项目图省事「一个实体类传到底」,等于把这盒带泥的原材料连盒子一起端上客人餐桌:内部字段、删除标记、密码哈希全曝光。第六节会把这条链写成真实代码。
同一张电影票不能出两次。 你在窗口打印失败,转身让工作人员「再打一次」,正确的系统不会给你第二张座位号相同的票,而是把第一次那张重新递给你——它认的是票号,不是「你这次来问」。BeeOrder 的下单幂等就是这个结构:客户端为一次下单意图生成一个 idempotentKey,数据库对它加唯一索引;重复提交撞在索引上,程序捕获冲突后查出第一单原样返回。「先查一遍有没有、没有再插」那套写法看着聪明,两个人同时查到「没有」就各下一单——窗口前面排两个人同时喊「再打一次」,座位就真没了。第八节专讲这件事。

这张图就是本篇的地图:左下两支(资源与路径、方法语义)和右上两支(状态码、错误码表)由第五、七节落地,中间的分页与幂等落在第九、八节,最下面那条「版本与兼容」是全篇反复强调的那句「接口只能增不能改」。看的时候顺手对照一件事:任何一个要素没写清,联调那天一定有人来问你——第七节的错误码表和第十三节的报错速查,就是提前把这些问答写好。
学完这一篇,你应该能回答三个问题:
- 为什么订单项非要冗余存商品名和单价?商品改名之后,历史订单会发生什么?
- 为什么金额必须
DECIMAL(12,2)+BigDecimal,而不能用double?举一个具体的算错的例子。 - 「先查一下有没有重复,再决定插不插」这段代码错在哪?把它改成什么才算真正的幂等?
上一篇我们把 BeeOrder 的领域模型、状态机和接口清单定死了。这一篇要把它落成两样能交付的东西:数据库表结构与API 契约。但在动手写 CREATE TABLE 之前,必须先把顺序摆正——表结构是领域模型的落地结果,不是设计的起点。
很多项目的坏味道,都是从「先设计表」开始的:拿到需求先画 ER 图,字段怎么方便怎么来,结果业务规则无处安放,越写越拧巴。正确顺序是:先想清业务规则和聚合边界,再让表去承载它们。
三条建表原则:
- 先领域后表结构:领域对象(User / Order / Inventory)是主语,表只是它的持久化形态。领域里没有的概念,表里也不该凭空冒出来。
- 以聚合为单位建表:一个聚合根 = 一张主表 + 若干从表,从表通过外键指向聚合根,对外只能通过聚合根改动。
order与order_item是同一聚合,user与order是两个聚合。 - 跨聚合用 ID 引用,不建强外键:
order.user_id只存用户 ID,不加FOREIGN KEY约束。这不是偷懒,而是为了解耦与分布式扩展留后路——强外键在高并发下会带来额外的锁与级联风险。
第三范式(3NF)要求消除冗余:一个事实只存一处。但订单场景里,我们故意违反 3NF——order_item 冗余存了 product_name 和 product_price。
| 场景 | 范式选择 | 原因 |
|---|---|---|
| 用户、商品的基础信息 | 遵循 3NF | 一处维护、多处引用,改一次全局生效 |
| 订单项的商品名与单价 | 反范式(冗余快照) | 历史订单必须冻结「下单那一刻」的事实 |
| 订单总额 total_amount | 冗余汇总 | 避免每次查询都 SUM(order_item),且总额本身就是业务事实 |
讲透这条,需要一个现场:5 月 1 日,用户以 5999 元买下一台「蜂巢智能音箱」;6 月 1 日,运营把它改名为「蜂巢音箱 Pro」并降价到 4999 元。如果 order_item 只存 product_id,那么用户 7 月查看历史订单、平台结算 6 月的退款、财务开具发票时,程序 join 到 product 表读到的都是新名字、新价格——用户会质问「我买的明明是 5999」,对账会彻底对不上。
快照冗余的不是「重复数据」,而是「那个时刻的事实」。 凡是「曾经是什么价、什么名、什么地址」,都必须在业务发生的那一刻冻结下来。理解了这一点,你就理解了为什么订单系统天然是「反范式」的重灾区。
把两种设计并排放上桌,账单一次算清——左边是教科书式的严格 3NF,右边是 BeeOrder 采用的按需冗余:

读这张图别急着站队,只比两笔账:左边的「省」省在存储,赔掉的是历史事实与查询性能;右边的「费」费在多存两个字段,买到的是永不变样的历史和一页一查。落到手上只剩一个判断句:这个字段三年后还应该显示当时的值吗?该,就快照;不该(比如商品营销文案),就老老实实 join 实时读。
反范式不是随心所欲地冗余,而是有原则地冗余——只冗余「会随时间改变、且历史值必须保留」的字段。商品的描述文案可以 join 实时读,商品的下单价格必须快照。
八个表分散在两个聚合域里,先看全局再逐张落地:

BeeOrder 用 MySQL 8,引擎一律 InnoDB(要事务、要行锁),字符集 utf8mb4(要存 emoji 和中文),时间用 DATETIME(3)(毫秒精度)。其中三件事直接写进建表语句,剩下一件「连接与运行配置」要落在 application.yml 上——先把配置文件骨架交给生成器:
server:
port: 8080
spring:
application:
name: beeorder
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
生成结果里对照记两笔:DATETIME(3) 的毫秒精度要靠连接参数保住;生成器写的 serverTimezone=Asia/Shanghai 只是默认起点,先别改——第十节讲清楚为什么全项目要统一成 UTC。
CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户 ID,自增主键', `phone` VARCHAR(20) NOT NULL COMMENT '手机号,登录账号', `password` CHAR(60) NOT NULL COMMENT 'BCrypt 哈希,定长 60,永不存明文', `nickname` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '昵称', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 正常 0 禁用', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0 否 1 是', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_phone` (`phone`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| id | BIGINT UNSIGNED | 自增主键即聚簇索引,写入有序、页分裂少;unsigned 扩大可用范围 |
| phone | VARCHAR(20) + 唯一索引 | 登录账号必须唯一,20 位足够容纳国际号码 |
| password | CHAR(60) | BCrypt 输出定长 60,用 CHAR 省去长度前缀;永不存明文 |
| status / deleted | TINYINT | 取值只有两三个,1 字节足够,比 VARCHAR 更省空间 |
| create_time | DATETIME(3) | 毫秒精度,便于排查并发问题与稳定排序 |
CREATE TABLE `product` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品 ID', `name` VARCHAR(128) NOT NULL COMMENT '商品名', `subtitle` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '副标题', `price` DECIMAL(12,2) NOT NULL COMMENT '单价(元),精确金额', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架', `extra_info` JSON NULL COMMENT '扩展信息(标签/图片),结构不定', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_product_status` (`status`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| price | DECIMAL(12,2) | 金额必须精确,double 会因为二进制表示丢失精度(见第四节现场) |
| extra_info | JSON | 标签、图片等结构不定;MySQL 8 原生 JSON 可校验、可函数查询 |
| status | TINYINT | 上架/下架二值,适合做低基数过滤 |
CREATE TABLE `inventory` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '库存 ID', `product_id` BIGINT UNSIGNED NOT NULL COMMENT '商品 ID', `available` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '可售数量,无符号天然拒绝负值', `locked` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '锁定数量(下单未支付)', `version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_inventory_product` (`product_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| product_id | BIGINT UNSIGNED + 唯一索引 | 一商品一库存行;唯一索引既是约束又加速按商品查询 |
| available | INT UNSIGNED | 无符号列在严格模式下, 减到负数会直接报错,是数据库层的兜底防线 |
| locked | INT UNSIGNED | 下单锁库存、支付转扣减、超时释放,都围绕它变化 |
| version | INT UNSIGNED | 备用的乐观锁版本号,为后续并发方案留余地 |
CREATE TABLE `inventory_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '流水 ID', `product_id` BIGINT UNSIGNED NOT NULL COMMENT '商品 ID', `change_qty` INT NOT NULL COMMENT '变动数量,扣减为负、回补为正', `type` VARCHAR(16) NOT NULL COMMENT 'DEDUCT 扣减 / RELEASE 释放 / RESTORE 回补', `ref_id` BIGINT UNSIGNED NOT NULL COMMENT '关联单号(订单 ID 等)', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_invlog_product_time` (`product_id`, `create_time`), UNIQUE KEY `uk_invlog_ref_type` (`ref_id`, `type`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存流水表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| change_qty | INT(有符号) | 一条流水同时表达方向:扣减为负、回补为正 |
| type | VARCHAR(16) | 存枚举名,排障时一眼看懂这笔流水是什么动作 |
| (ref_id, type) | 联合唯一索引 | 同一单号、同一动作只能写一次,天然实现扣减幂等 |
order 是 SQL 关键字,表名必须加反引号——这是本篇第一个必须记住的坑(详见第十节)。
CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单 ID', `order_no` VARCHAR(32) NOT NULL COMMENT '对外业务订单号', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单用户 ID', `total_amount` DECIMAL(12,2) NOT NULL COMMENT '订单总额(元)', `status` VARCHAR(16) NOT NULL COMMENT 'CREATED/PAID/SHIPPED/COMPLETED/CLOSED/REFUNDED', `idempotent_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键', `receiver_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '收货人', `receiver_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '收货电话', `receiver_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收货地址', `pay_time` DATETIME(3) NULL COMMENT '支付时间', `ship_time` DATETIME(3) NULL COMMENT '发货时间', `close_time` DATETIME(3) NULL COMMENT '关闭时间', `expire_time` DATETIME(3) NOT NULL COMMENT '超时关单时间 = 创建 + 30 分钟', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`), KEY `idx_order_user_status_time` (`user_id`, `status`, `create_time`), KEY `idx_order_status_expire` (`status`, `expire_time`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| order_no | VARCHAR(32) + 唯一索引 | 对外展示的业务单号,避免暴露自增 ID 的规模与增长速度 |
| status | VARCHAR(16) | 存枚举名,与状态机一字不差,排障直观(故不是 tinyint) |
| idempotent_key | VARCHAR(64) | 客户端幂等键;(user_id, idempotent_key) 唯一约束实现下单幂等 |
| expire_time | DATETIME(3) + 联合索引 | 超时关单的扫描依据,落库存储避免每次重复计算 |
| total_amount | DECIMAL(12,2) | 订单总额是业务事实,冗余存储避免每次 SUM 订单项 |
CREATE TABLE `order_item` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单项 ID', `order_id` BIGINT UNSIGNED NOT NULL COMMENT '订单 ID', `product_id` BIGINT UNSIGNED NOT NULL COMMENT '商品 ID', `product_name` VARCHAR(128) NOT NULL COMMENT '下单时商品名快照', `product_price` DECIMAL(12,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL COMMENT '购买数量', `amount` DECIMAL(12,2) NOT NULL COMMENT '小计 = 单价 × 数量', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_item_order` (`order_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单项表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| product_name / product_price | VARCHAR + DECIMAL(快照) | 历史订单不被商品改名、改价影响——第一节讲的「那个时刻的事实」 |
| amount | DECIMAL(12,2) | 小计 = 单价 × 数量,冗余存储避免每次查询重复计算 |
| order_id | 普通索引 | 按订单查明细是最高频的读,必须有索引 |
CREATE TABLE `payment` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '支付记录 ID', `order_id` BIGINT UNSIGNED NOT NULL COMMENT '订单 ID', `trade_no` VARCHAR(64) NOT NULL COMMENT '支付网关流水号,回调幂等键', `amount` DECIMAL(12,2) NOT NULL COMMENT '支付金额', `channel` VARCHAR(16) NOT NULL DEFAULT 'MOCK' COMMENT '支付渠道', `status` VARCHAR(16) NOT NULL COMMENT 'INIT/SUCCESS/FAILED', `callback_time` DATETIME(3) NULL COMMENT '回调到达时间', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_trade_no` (`trade_no`), KEY `idx_payment_order` (`order_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '支付记录表';| 字段 | 类型 | 为什么这么选 |
|---|---|---|
| trade_no | VARCHAR(64) + 唯一索引 | 网关流水号唯一,是支付回调幂等的核心防线 |
| status | VARCHAR(16) | INIT/SUCCESS/FAILED,与订单状态机配合完成「只成功一次」 |
| callback_time | DATETIME(3) | 记录回调实际到达时间,便于与网关对账 |
八张表到齐了,先别急着看索引——把一次真实下单在表之间的行走路线看一遍:哪一步落哪张表、哪一步不该碰别的表。这张动图是第三节到第九节所有设计的共同底稿:

索引不是「给常用字段都加一个」。每一张二级索引都会让写入变慢(每次 insert/update 都要维护一棵 B+ 树),所以索引的设计准则是「只为真实的高频查询路径服务」。
先给实际索引 DDL——除了建表语句里内联的那些,订单表还要补两条联合索引:
-- 我的订单列表:按用户 + 状态 + 时间倒序ALTER TABLE `order` ADD KEY `idx_order_user_status_time` (`user_id`, `status`, `create_time`);-- 定时关单扫描:先按状态筛,再按过期时间取最早的若干条ALTER TABLE `order` ADD KEY `idx_order_status_expire` (`status`, `expire_time`);联合索引 (user_id, status, create_time) 的排序是「先按 user_id 排,user_id 相同再按 status 排,再相同才按 create_time 排」。所以它只能被从最左列开始、连续命中的条件使用:
| 查询条件 | 能否命中索引 | 说明 |
|---|---|---|
WHERE user_id = ? | 命中 | 用最左列 user_id |
WHERE user_id = ? AND status = ? | 命中 | 前两列连续命中 |
WHERE user_id = ? AND status = ? ORDER BY create_time DESC | 完美命中 | 三列全用上,排序也能走索引 |
WHERE status = ? | 不命中 | 跳过了最左列 user_id,索引对 status 不再有序 |
最后一行就是「最左前缀」的铁律:联合索引的列顺序,决定它能服务哪些查询。把选择性最高的列(user_id)放最左,能让更多查询吃到索引。
WHERE status = ? 这种单个低基数列的查询,别指望联合索引帮忙。真需要按状态全表筛,就单独为它建 (status, ...) 开头的索引,或者接受全表扫描。
| 不该建索引的字段 | 原因 |
|---|---|
| deleted | 基数只有 2,选择性极差;应作为联合索引的从属列而非独立索引 |
| status(单独) | 百万级订单里区分度低,单列索引几乎没用;要用作联合索引首列时另说 |
| create_time(单独) | 范围查询会扫过大量行;作为联合索引末列更有效 |
| extra_info(JSON) | 对 JSON 建索引膨胀大、写法受限;真需要检索就抽成独立列 |
| 频繁更新的字段 | 每次 UPDATE 都要维护索引 → 写入放大,得不偿失 |
一个经验法则是——索引服务于「读多写少 + 高选择性」的条件。反过来说,如果一个字段几乎每次写都要变,或者查询时命中的行数超过全表 20%,索引基本帮不上忙。
把上一节的逐表说明汇总成一张总表,方便对照:
| 业务字段 | 选型 | 备选 | 理由(为什么不选备选) |
|---|---|---|---|
| 金额 | DECIMAL(12,2) | DOUBLE / FLOAT | 浮点用二进制表示小数,无法精确表达 0.1,累加会漂移 |
| 状态 | VARCHAR(16) 存枚举名 | TINYINT 存数字 | 可读、可自解释、排障直观;代价是略多几个字节,值 |
| 时间 | DATETIME(3)(存 UTC) | TIMESTAMP / BIGINT | 与业务时区解耦、无 2038 问题、毫秒精度 |
| 主键 ID | BIGINT UNSIGNED 自增 | UUID / 雪花 | 自增写入有序、聚簇索引友好;对外另给 order_no |
| 扩展字段 | JSON 类型 | VARCHAR / TEXT | MySQL 8 原生 JSON 有校验、有函数、语义清晰 |
| 删除标记 | deleted TINYINT | 物理删除 | 用户、商品可逻辑删;订单是财务凭证,绝不物理删 |
| 乐观锁 | version INT | 无版本、纯行锁 | 为「读-改-写」场景留余地,行锁与乐观锁可按需切换 |
不用被「精度」两个字吓到,跑一行代码就懂了:
System.out.println(0.1 + 0.2); // 0.30000000000000004System.out.println(new java.math.BigDecimal("0.1") .add(new java.math.BigDecimal("0.2"))); // 0.3System.out.println(2.99 * 100); // 298.99999999999994- 第一行:
double无法精确表示 0.1 和 0.2,相加得到0.30000000000000004 - 第二行:用
BigDecimal字符串构造,得到精确的 0.3 - 第三行:连「元转分」这种最常见的换算,浮点都能算错
数据库侧的应对就是 DECIMAL(12,2):12 位总长度含 2 位小数,最大可存到 99,999,999,999.99,足够业务用;Java 侧对应 BigDecimal。在金融相关代码里,任何一处 double 都可能是资损的起点。
float / double 只能用于「本来就是近似值」的场景,例如坐标、评分、比例。凡是「钱、库存、数量」,一律精确类型。

上一篇给了 15 个接口的清单,这一篇要给每个接口补上「请求长什么样、响应长什么样、权限是什么」,让它成为前后端都能照着写代码的契约。URL 遵循 REST 规范:名词复数、层级表达归属、过滤分页走查询串、路径里不放动词。
| 方法 | 路径 | 权限 | 请求要点 | 响应要点 |
|---|---|---|---|---|
| POST | /api/v1/auth/register | 公开 | 手机号 + 密码 | 新用户 id |
| POST | /api/v1/auth/login | 公开 | 手机号 + 密码 | JWT + 用户信息 |
| GET | /api/v1/auth/me | 登录 | 无(从 JWT 取) | 当前用户 DTO |
| GET | /api/v1/products | 公开 | page / size / keyword | 分页商品 VO |
| GET | /api/v1/products/{id} | 公开 | 路径参数 id | 商品详情 VO |
| GET | /api/v1/inventory/{productId} | 公开 | 路径参数 productId | 可售 / 锁定数量 |
| POST | /api/v1/orders | 登录 | CreateOrderRequest + 幂等键 | 新建订单 VO |
| GET | /api/v1/orders | 登录 | OrderQuery(状态/时间/分页) | 分页订单 VO |
| GET | /api/v1/orders/{id} | 登录 | 路径参数 id | 订单详情 VO |
| POST | /api/v1/orders/{id}/cancel | 登录 | 路径参数 id | 更新后的订单 VO |
| POST | /api/v1/payments | 登录 | orderId + 渠道 | 支付单与跳转信息 |
| POST | /api/v1/payments/callback | 公开(验签) | 网关回调报文 | 固定成功应答 |
| GET | /api/v1/payments/{orderId} | 登录 | 路径参数 orderId | 支付记录 VO |
| GET | /api/v1/notifications | 登录 | page / size | 分页通知 VO |
| GET | /api/v1/admin/stats/orders | 管理员 | begin / end date | 按天汇总 VO |
一份契约光有表格还不够,得让调用方「看一眼示例就会用」。以下是三个最关键接口的完整报文。
下单请求——注意客户端幂等键 idempotentKey 是必填:
POST /api/v1/ordersAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9...Content-Type: application/json{ "receiverName": "张三", "receiverPhone": "13800138000", "receiverAddr": "深圳市南山区科技园 1 号楼 802", "idempotentKey": "9f2c1e0a-3b7d-4c11-8e2a-create-order", "items": [ { "productId": 1001, "quantity": 2 } ]}下单成功响应——data 里是 OrderVO,不含 userId、不含 deleted 等内部字段:
{ "code": 0, "message": "ok", "data": { "orderNo": "202610071530001234", "status": "CREATED", "totalAmount": 11998.00, "expireTime": "2026-10-07T07:30:00Z", "items": [ { "productId": 1001, "productName": "蜂巢智能音箱", "productPrice": 5999.00, "quantity": 2, "amount": 11998.00 } ] }, "traceId": "a1b2c3d4", "timestamp": 1759827000000}业务失败响应——库存不足返回错误码 1001,data 里带上诊断信息,方便前端提示与排障:
{ "code": 1001, "message": "库存不足", "data": { "productId": 1001, "available": 1, "requested": 2 }, "traceId": "e5f6a7b8", "timestamp": 1759827004321}契约定死之后,请求进入 Spring 的那条「解析→校验→分派」链路就有了确定的形状:路径参数走 @PathVariable,请求体走 @RequestBody + Bean Validation,两者在 Controller 方法签名上合流。
下面四个实验把这条链路拆开给你看。实验一:校验失败的一站——把上面那份成功报文换成少写一个必填字段,看请求死在哪、返回长什么样:
实验二:对象在各层的换乘。第六节那张「采购单 → 备料盒 → 成品」不是比喻而已,它对应一次真实的类型转换;切到「越层调用代价」能看到不换乘的下场:
实验三:Java 对象怎么变成你看到的那段 JSON。契约里的响应体不是自动出现的——消息转换器(HttpMessageConverter,负责把返回值按 Accept 头翻成 JSON/字符串的那个组件)决定它长什么样:
实验四:异常出口。第七节错误码表的全部意义就在这——业务异常必须有唯一出口,否则前端拿到的是 HTML 错误页而不是信封:
契约的价值在于「先定后写」。契约一旦冻结,前端可以立刻用 mock 数据开工,后端照契约实现;联调时若对不上,先看契约而不是互相甩锅——这就是动画里那六步所强调的「契约先行」。
把上面三段报文倒回去对照动图看第二遍,每一帧正好落在一段东西上:第 1 帧的用例就是 #43 第二节那张「提交订单」故事卡;第 2 帧产出的东西是第五节那份 CreateOrderRequest(字段、类型、约束一起定死);第 3 帧对应 OrderVO 加错误码表;第 4 帧就是本节那张接口清单;第 5、6 帧是前后端各写各的、最后拿 curl 对上。读哪一帧觉得空,就说明那一件的交付物还没写出来:

契约是纸面的,工具是活的。把应用启动到内核控制台,亲手把这份契约里最要命的几条路径跑一遍:同一个幂等键的两种到达方式(重复与首达)、分页 SQL 的改写、以及消息转换器对同一份响应的翻译:
很多项目图省事,Controller 直接把数据库实体 Order 返回给前端。这在 demo 里没问题,上了生产就是三颗雷:
| 直接返回 Entity 的风险 | 具体后果 |
|---|---|
| 安全泄露 | Entity 含 password、deleted、内部标记,序列化出去即泄密 |
| 强耦合 | 表结构一改(加字段/改字段名),前端接口跟着崩 |
| 循环引用 | Order ↔ OrderItem 双向引用,Jackson 序列化直接栈溢出 |
所以 BeeOrder 严格做三层隔离:Entity 贴数据库、DTO 收请求、VO 出响应。三者字段可以高度相似,但职责与生命周期完全不同。
把同一次下单里三种对象的「换乘」点成一条线看,就明白三隔离挡住的是什么——注意最后一格:不换乘的下场不是报错打回,而是把内部字段直接送出大门:
请求侧 DTO 承载「格式与校验规则」,把非法输入挡在 Controller 门口:
package com.beeorder.order.dto;import jakarta.validation.Valid;import jakarta.validation.constraints.*;import java.util.List;public class CreateOrderRequest { @NotBlank(message = "收货人不能为空") @Size(max = 32) private String receiverName; @NotBlank(message = "收货电话不能为空") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String receiverPhone; @NotBlank(message = "收货地址不能为空") @Size(max = 255) private String receiverAddr; @NotEmpty(message = "订单项不能为空") @Size(max = 50, message = "单个订单最多 50 个商品") @Valid private List<Item> items; @NotBlank(message = "幂等键不能为空") @Size(max = 64) private String idempotentKey; public static class Item { @NotNull(message = "商品 ID 不能为空") private Long productId; @NotNull @Min(1) @Max(999) private Integer quantity; // getters / setters 省略 } // getters / setters 省略}响应侧 VO 只暴露「前端需要的、且安全的」字段,并在这里完成脱敏:
package com.beeorder.order.vo;import java.math.BigDecimal;import java.time.LocalDateTime;import java.util.List;public class OrderVO { private String orderNo; // 只给业务单号,不给自增 id private String status; // 状态机枚举名 private BigDecimal totalAmount; private List<OrderItemVO> items; private String receiverName; private String receiverPhone; // 输出时脱敏为 138****8000 private LocalDateTime createTime; private LocalDateTime payTime; // 注意:不含 userId(前端无需知道)、不含 deleted、不含 version}Entity 转 VO 用一层薄薄的转换器,保持手写、可控、可调试:
package com.beeorder.order.converter;public final class OrderConverter { private OrderConverter() {} public static OrderVO toVO(Order order, List<OrderItem> items) { OrderVO vo = new OrderVO(); vo.setOrderNo(order.getOrderNo()); vo.setStatus(order.getStatus()); vo.setTotalAmount(order.getTotalAmount()); vo.setReceiverName(order.getReceiverName()); vo.setReceiverPhone(MaskUtil.phone(order.getReceiverPhone())); // 脱敏 vo.setCreateTime(order.getCreateTime()); vo.setPayTime(order.getPayTime()); vo.setItems(items.stream().map(OrderConverter::toItemVO).toList()); return vo; }}| 转换方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 手写转换器 | 零依赖、逻辑可见、断点可调试 | 字段多时重复代码多 | BeeOrder:字段少、需要脱敏等定制逻辑 |
| MapStruct | 编译期生成、字段多时省力 | 引入注解处理器、复杂映射仍需自定义 | 大项目、DTO 与 Entity 近乎一一对应时 |
BeeOrder 选择手写转换器。原因很实在——我们的转换里夹着脱敏、状态翻译、字段拼装这类定制逻辑,MapStruct 遇到这些还是得写自定义方法,不如统一手写,简单直接。
所有接口统一包一层信封 Result<T>,让前端只判断一个 code 就能分流:
package com.beeorder.common.response;public class Result<T> { private int code; // 0 表示成功 private String message; private T data; private String traceId; // 链路追踪 ID,排障用 private long timestamp; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; r.timestamp = System.currentTimeMillis(); return r; } public static <T> Result<T> fail(ErrorCode ec) { Result<T> r = new Result<>(); r.code = ec.getCode(); r.message = ec.getMessage(); r.timestamp = System.currentTimeMillis(); return r; } // getters / setters 省略}错误码用枚举集中管理,每个码绑定 HTTP 状态,这样监控、网关、日志都能看懂:
package com.beeorder.common.response;public enum ErrorCode { OK(0, "ok", 200), STOCK_NOT_ENOUGH(1001, "库存不足", 409), ORDER_NOT_FOUND(1002, "订单不存在", 404), ORDER_STATUS_ILLEGAL(1003, "订单状态不允许该操作", 409), DUP_PAY_CALLBACK(2001, "重复支付回调,已忽略", 200), PAY_AMOUNT_MISMATCH(2002, "支付金额与订单不符", 400), UNAUTHORIZED(3001, "未认证或令牌已过期", 401), FORBIDDEN(3002, "无权访问该资源", 403), PARAM_INVALID(4001, "参数校验失败", 400), INTERNAL_ERROR(5000, "系统繁忙,请稍后重试", 500); private final int code; private final String message; private final int httpStatus; ErrorCode(int code, String message, int httpStatus) { this.code = code; this.message = message; this.httpStatus = httpStatus; } // getters 省略}BeeOrder 的错误码按「段」划分,一看前缀就知道归属:1xxx 业务域(订单/库存/支付)、2xxx 支付回调、3xxx 认证授权、4xxx 参数校验、5xxx 系统级。
| 错误码 | 含义 | HTTP 状态 | 触发场景 |
|---|---|---|---|
| 1001 | 库存不足 | 409 | 下单时 available < 请求数量 |
| 1002 | 订单不存在 | 404 | 查询或取消一个不存在的订单 |
| 1003 | 订单状态非法 | 409 | 对已关闭订单发起支付 |
| 2001 | 重复支付回调 | 200 | 同一 trade_no 二次回调(幂等,返回成功) |
| 2002 | 支付金额不匹配 | 400 | 回调金额 ≠ 订单金额 |
| 3001 | 未认证 | 401 | Token 缺失或过期 |
| 3002 | 无权限 | 403 | 访问他人的订单 |
| 4001 | 参数校验失败 | 400 | Bean Validation 校验不通过 |
| 5000 | 系统内部错误 | 500 | 未捕获的运行时异常 |
「一切皆 200,错误码藏在 body 里」是很多团队的历史包袱。它让前端省事,却让网关的重试策略、监控的错误率统计、缓存的失效判断全部失灵。业务错误该给的 HTTP 状态就要给,信封里的 code 只是给业务层补充更细的语义。
幂等是分布式系统里最容易出错、也最容易被忽略的一环。BeeOrder 有两个必须幂等的写入点:下单与支付回调。
先看清「重复到达」在系统里长什么样——同一个幂等键的第二次请求,整条路会在唯一索引上折返。这张动图的每一帧,后面 8.1 的代码里都能找到对应行:

用户手抖点了两次「提交订单」,或者网络超时后客户端自动重试,都会让同一个下单意图到达两次。防重最可靠的手段不是「先查有没有」,而是用数据库的唯一约束把重复挡在门外:
@Transactionalpublic OrderVO createOrder(Long userId, CreateOrderRequest req) { try { // 组装并插入订单;(user_id, idempotent_key) 上有唯一索引 uk_order_idem Order order = assemble(userId, req); orderMapper.insert(order); return doCheckout(order, req); // 扣库存、写订单项 } catch (DuplicateKeyException e) { // 唯一键冲突 = 这是一次重复提交:查出首次结果直接返回 Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); log.info("重复下单,返回首次结果 orderNo={}", exist.getOrderNo()); return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId())); }}-- 幂等的基石:同一用户、同一幂等键只能有一行UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`)idempotent_key由客户端生成(如 UUID),一次「下单意图」对应一个键,重试时复用(user_id, idempotent_key)唯一索引保证:重复插入必然失败,DuplicateKeyException就是「重复」的信号- 捕获冲突后不要报错,而是返回首次的结果——对用户而言,两次点击得到同一个订单,这就是幂等
类比:幂等键就是印在票角的那个票号。 窗口重打一张票之前先看票号,同一个号只认第一次——所以「再打一次」拿到的是同一张座位,而不是第二个座位。把这条规矩从纸面挪进数据库,靠的就是上面那两行:唯一索引替你把重复钉死。少了它,两个人同时喊「再打一次」,系统就会真给你两张:多一行订单,还多扣一次库存。
不过先别急着翻页:上面的代码是「事后接住」的写法,很多人第一反应会写成更直觉的「先查一遍,没有再插」。把它放进两个线程里逐行走一遍,那个毫秒宽的窗口就藏不住了:
String key = req.getIdempotentKey();Order exist = orderMapper.selectByUserIdAndIdemKey(userId, key);if (exist != null) { return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId()));}Order order = assemble(userId, req);orderMapper.insert(order);return doCheckout(order, req);OrderService.createOrder(OrderService.java:38)OrderController.create(OrderController.java:38)结论摆在这:先查再插的每一步单看都没错,错在它替数据库做了一个数据库从没答应过的承诺。8.2 的支付回调走的是同一套思路,只是防线换成了 trade_no。
支付网关的回调经常重发(网关没收到你的成功应答就会重试)。防重靠两道防线:payment.trade_no 唯一约束挡住重复落库,订单状态机挡住重复流转:
@Transactionalpublic void handleCallback(PayCallbackDTO dto) { Payment pay = paymentMapper.selectByTradeNo(dto.getTradeNo()); if (pay != null && PayStatus.SUCCESS.name().equals(pay.getStatus())) { log.info("重复支付回调,已忽略 tradeNo={}", dto.getTradeNo()); return; // 第一道:已成功,直接返回 } // 第二道:条件更新,只有 CREATED 才能转 PAID int rows = orderMapper.markPaid(dto.getOrderId(), OrderStatus.CREATED, OrderStatus.PAID); if (rows == 0) { log.warn("订单状态不允许支付,忽略 orderId={}", dto.getOrderId()); return; } paymentMapper.updateStatus(dto.getTradeNo(), PayStatus.SUCCESS); inventoryService.commitLocked(dto.getOrderId()); // 锁定库存转实扣}三个幂等点的字段设计汇总:
| 幂等场景 | 幂等键 | 约束方式 |
|---|---|---|
| 下单 | order.idempotent_key(客户端 UUID) | 唯一键 (user_id, idempotent_key) |
| 支付回调 | payment.trade_no(网关流水号) | 唯一键 uk_payment_trade_no + 状态校验 |
| 库存扣减 | inventory_log 的 (ref_id, type) | 唯一键 uk_invlog_ref_type |
幂等的本质是「用不可变的事实去约束可变的操作」。trade_no、idempotent_key 都是外部给定的、天然唯一的事实,把它们做成唯一索引,就得到了一道不依赖应用逻辑的、并发安全的防线。
列表类接口统一返回 PageResult<T>,绝不返回裸数组——前端需要 total 才能渲染分页器:
package com.beeorder.common.response;import java.util.List;public class PageResult<T> { private List<T> list; private long total; // 总条数 private int page; // 当前页(从 1 开始) private int size; // 每页条数 private int pages; // 总页数 private boolean hasNext; // 是否还有下一页 public static <T> PageResult<T> of(List<T> list, long total, int page, int size) { PageResult<T> r = new PageResult<>(); r.list = list; r.total = total; r.page = page; r.size = size; r.pages = (int) ((total + size - 1) / size); r.hasNext = (long) page * size < total; return r; } // getters / setters 省略}条件查询统一收进一个查询对象,避免 Controller 方法签名堆满散参数:
package com.beeorder.order.dto;import java.time.LocalDateTime;public class OrderQuery { private String status; // 可选:按状态筛选 private LocalDateTime beginTime; // 可选:创建时间起点 private LocalDateTime endTime; // 可选:创建时间终点 private Integer page = 1; private Integer size = 20; // 服务端上限 100 private Long userId; // 由 JWT 注入,禁止前端传入! public int getOffset() { return (Math.max(page, 1) - 1) * Math.min(size, 100); } // getters / setters 省略}分页参数约定,写进团队规范:
| 参数 | 默认 | 上限 | 说明 |
|---|---|---|---|
| page | 1 | — | 从 1 开始,小于 1 一律按 1 处理 |
| size | 20 | 100 | 超过 100 直接截断到 100 |
| sort | create_time desc | 白名单 | 只允许白名单字段,防止注入与低效排序 |
不限制 size 上限,攻击者一个 ?size=1000000 就能拖走整张表,把数据库和内存一起打爆。分页大小必须由服务端设上限,这在任何对外接口上都不是可选项。
-- 错误:MySQL 解析器会把 order 当成 ORDER BY 的语法部分,直接报语法错误SELECT * FROM order WHERE id = 1; -- ERROR 1064-- 正确:用反引号包起来,声明它是一个标识符SELECT * FROM `order` WHERE id = 1;这条同样适用于 MyBatis:实体类上的 @TableName("order")、XML 里手写的每条 SQL,都必须带反引号。漏一个地方,就可能在某次上线后收到一条 You have an error in your SQL syntax。
第四节已经演示过浮点误差现场。团队约定:金额一律 DECIMAL + BigDecimal,元转分、分转元都要用 BigDecimal 的 movePointLeft/Right,禁止出现 double。
这是团队最容易扯皮的地方,BeeOrder 的约定是数据库存 UTC、应用层用 LocalDateTime(视作 UTC)、展示层按用户时区渲染:
- 数据库:MySQL 会话设置
time_zone = '+00:00',JDBC URL 加connectionTimeZone=UTC - 应用层:统一用
LocalDateTime/Instant,不混用java.util.Date - 展示层:前端拿到 ISO-8601 带
Z的字符串,按浏览器本地时区渲染
好处是:跨时区部署时不会出现「同一订单在不同机器上时间不一样」,夏令时切换也不会错位。
这一篇把上一篇的「合同」落成了两样可交付物。表结构上,我们以聚合为单位建了 8 张表,讲透了为什么订单项要冗余价格快照、金额为什么必须 DECIMAL、索引为什么只用最左前缀;API 契约上,我们把 15 个接口补齐了请求/响应/权限,用 Result<T> 统一信封、用分节错误码统一失败语义、用唯一索引 + 状态机实现了下单与支付回调的幂等。至此,BeeOrder 的「地基」已经完整——下一篇我们要在这套表和契约之上,写真正跑得动的核心业务:下单、原子扣库存、支付回调、超时关单。
第九节定了「size 服务端上限 100」。这个数到底该定多少?定小了正常用户抱怨,定大了数据库替你加班——而且攻击者永远先看你是不是没设。下面这个沙盘把「单次抓取条数」做成三档开关,切一档同屏看响应时间、连接占用和 total 的算法代价:
GET /api/v1/orders?size=100 → 21ms应用内存峰值:96MB走 idx_order_user_status_time,排序不吃 filesortCOUNT(*) 命中覆盖索引:1.8ms超过 100 直接截断并回显 size=100,前端能看出来
判断上限的口径不是「我最多能给多少」,而是「一屏渲染得完多少 + 网络扛得住多大」。真需要批量导出,走异步任务生成文件,绝不要把它做成一个带大 size 的同步查询。
先来一道热身题,考第四节那个浮点现场:
再来一道综合题,把第二、三、八节串起来:
下表每一行的「报错原文」都可以整段复制去搜索。新手在这一篇最容易撞上的墙有三类:唯一键冲突看不懂、分页数字对不上、时间和钱的精度莫名漂移。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
org.springframework.dao.DuplicateKeyException: Duplicate entry '10015-9f2c1e0a' for key 'order.uk_order_idem' | 唯一索引挡下了重复插入。绝大多数情况下这不是 Bug,而是幂等生效;少数情况是真的重复数据被塞进了不该有的列 | 先看索引名:uk_order_idem 是幂等键(应捕获后返回首次结果),uk_payment_trade_no 是回调重发(应记日志后返回成功),uk_inventory_product 才可能是真脏数据(查商品是否被重复建档) | 本篇第八节 · #31 事务 internals |
org.springframework.dao.DataIntegrityViolationException: ... Column 'receiver_addr' cannot be null | 这张网兜住的是所有违反完整性约束的写入:非空列为空、外键找不到父行、字段超长被拒。Spring 把 MySQL 的一串 errno 统一包成这一个异常,所以光看类名定位不到具体哪一条 | 别看类名,看 Cause by: 后面那句:java.sql.SQLIntegrityConstraintViolationException: Column 'xxx' cannot be null 里的列名就是答案。若 cause 是 Data too long for column,那是长度不够而非空值 | 本篇第二节 · #25 校验与异常 |
SQL Error: 1690, SQLState: 22003 — BIGINT UNSIGNED value is out of range in '(beeorder.inventory.available - ?)' | available 建成了 INT UNSIGNED,无符号列一旦被减成负数就直接报错。这其实是好事:它替你在数据库层挡住了超卖的最后一步 | 别把列改成有符号来「消掉报错」!应在 SQL 里加条件 WHERE available >= #{qty},让扣不动的时候影响行数为 0,由 Service 抛「库存不足」(错误码 1001) | 本篇第三节 · #45 防超卖三种方案 |
Caused by: java.sql.SQLException: Incorrect string value: '\xF0\x9F\x98\x80' for column 'nickname' | \xF0\x9F\x98\x80 是一个 emoji 的四字节 UTF-8 编码,而 utf8 字符集最多存三字节 | 表和连接都用 utf8mb4(本篇建表语句已统一),JDBC URL 不要再写 characterEncoding=utf8;改完记得对已有表执行 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 | 本篇第二节 · #22 MySQL 字符集 |
分页返回 total: 1000000,但翻到最后一页只有 3 条;或者反过来 total 比实际少 | count 与 list 两条 SQL 的条件不一致:最常见的三条是 list 带了 deleted = 0 而 count 忘了、list join 了 order_item 导致行数翻倍、count 复用了带 LIMIT 的语句 | 强制让两者共用同一段 <where> 片段(MyBatis <sql id="orderWhere"> + <include refid>);join 一对多时用 COUNT(DISTINCT o.id);分页插件配 optimizeCountSql 时确认它没把你的 group by 吃掉 | 本篇第九节 · #29 MyBatis 实战 |
用户在浏览器看到的下单时间是 08:00,运营后台同一单显示 00:00,相差正好 8 小时 | 三层各用了各的时区:数据库会话是 UTC,某台机器 JVM 默认 Asia/Shanghai,还有一处把 LocalDateTime 直接当本地时间格式化输出 | 守住第十节的约定:库里存 UTC、JDBC 加 connectionTimeZone=UTC、应用内部一律 Instant/LocalDateTime(视作 UTC)、只在展示层转用户时区;跨层不许出现 Date 与 SimpleDateFormat | 本篇第十节 · #35 日志与时区排查 |
| 夏令时切换那天,定时关单提前一小时跑,把还没到点的订单关了 | 存储用 UTC 是对的,但算「30 分钟后」这件事不能靠裸的 now.plusMinutes(30) 与本地时钟比较;本地时间戳在 DST 切换日会重复或跳空一小时 | 过期时间统一用 UTC 计算并与 expire_time 比较;涉及人类作息的需求(如「每天早九点」)才用 ZonedDateTime 带时区运算,并在测试里显式覆盖 DST 那一天 | 本篇第十节 · #40 异步与定时任务 |
| 报表按天汇总的金额比明细少几分钱,逐月累积差到几元 | 每一步都在 double 上做累加,误差单向叠加;或者 Java 侧算完总额再让数据库重新汇总,两边舍入口径不同 | 金额一律 DECIMAL(12,2) + BigDecimal,舍入策略全项目统一成一处(例如 RoundingMode.HALF_UP 且只在最终展示时舍入);元分换算用 movePointRight/Left,不乘 100 | 本篇第四节 · #45 金额与库存一致性 |
这一篇的报错有个共同特征——大多数不会以红字异常出现(分页数字不对、时间差 8 小时、金额少几分钱都是静默的)。所以判据不能是「有没有报错」,而是「有没有对账」:上线前先拿一批真实数据做一次明细与汇总的双向核对,比任何告警都管用。
表里第一条 DuplicateKeyException 是新手的头号心理阴影——红字、堆栈,订单却没多。把它当成一次现场勘查逐帧来读:谁在拦、拦得对不对、你的代码该在哪一层接住它:
用户双击了提交按钮,第二次请求带着同一个幂等键到达。日志里出现这段栈,而订单数没变——先别慌,这是一次现场勘查。
三档难度。第一档给全套能跑的代码,照着敲就能看见返回 JSON。
目标:把 order_item 这张表、它的实体与 Mapper、以及一个带校验的下单接口骨架真正跑起来,并亲眼看到唯一索引和 Bean Validation 各自挡下什么。
第一步,建表脚本 src/main/resources/schema.sql(只留本次要用的四张表,MySQL 8 直接执行):
CREATE DATABASE IF NOT EXISTS beeorder DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;USE beeorder;CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `phone` VARCHAR(20) NOT NULL, `password` CHAR(60) NOT NULL, `nickname` VARCHAR(32) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_phone` (`phone`)) ENGINE = InnoDB;CREATE TABLE `product` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(128) NOT NULL, `price` DECIMAL(12,2) NOT NULL, PRIMARY KEY (`id`)) ENGINE = InnoDB;CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL, `total_amount` DECIMAL(12,2) NOT NULL, `status` VARCHAR(16) NOT NULL, `idempotent_key` VARCHAR(64) NOT NULL, `receiver_name` VARCHAR(32) NOT NULL, `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`)) ENGINE = InnoDB;CREATE TABLE `order_item` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_id` BIGINT UNSIGNED NOT NULL, `product_id` BIGINT UNSIGNED NOT NULL, `product_name` VARCHAR(128) NOT NULL, `product_price` DECIMAL(12,2) NOT NULL, `quantity` INT NOT NULL, `amount` DECIMAL(12,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_item_order` (`order_id`)) ENGINE = InnoDB;INSERT INTO `user` (`phone`, `password`, `nickname`) VALUES ('13800138000', '$2a$10$placeholder', '小明');INSERT INTO `product` (`id`, `name`, `price`) VALUES (1001, '蜂巢智能音箱', 5999.00);第二步,实体类(com.beeorder.order.domain.OrderItem)。注意 amount 用 BigDecimal,字段类型和列类型一一对应:
package com.beeorder.order.domain;import java.math.BigDecimal;public class OrderItem { private Long id; private Long orderId; private Long productId; private String productName; private BigDecimal productPrice; private Integer quantity; private BigDecimal amount; // getters / setters 省略}第三步,Mapper 接口(com.beeorder.order.mapper.OrderItemMapper)。简单 SQL 用注解,@Options 让自增主键回填到对象上:
package com.beeorder.order.mapper;import com.beeorder.order.domain.OrderItem;import org.apache.ibatis.annotations.Insert;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Options;import org.apache.ibatis.annotations.Param;import org.apache.ibatis.annotations.Select;import java.util.List;@Mapperpublic interface OrderItemMapper { @Insert("INSERT INTO order_item (order_id, product_id, product_name, product_price, quantity, amount) " + "VALUES (#{orderId}, #{productId}, #{productName}, #{productPrice}, #{quantity}, #{amount})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(OrderItem item); @Select("SELECT id, order_id AS orderId, product_id AS productId, " + "product_name AS productName, product_price AS productPrice, quantity, amount " + "FROM order_item WHERE order_id = #{orderId}") List<OrderItem> selectByOrderId(@Param("orderId") Long orderId);}第四步,Controller:只做「收请求 → 校验 → 交给 Service → 包装信封」,一行业务逻辑都不写(OrderService 留给 #45,这里先用占位实现跑通链路):
package com.beeorder.order.controller;import com.beeorder.common.response.Result;import com.beeorder.order.dto.CreateOrderRequest;import jakarta.validation.Valid;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/api/v1/orders")public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping public Result<OrderVO> create(@Valid @RequestBody CreateOrderRequest req) { // userId 必须来自 JWT,绝不能由前端传入 return Result.ok(orderService.createOrder(10015L, req)); }}第四步半,补一个够跑通链路的最小 Service(真正的编排与扣库存留给 #45,这里只演示幂等这一段)。注意 DuplicateKeyException 是被接住的,而不是抛给用户的 500:
package com.beeorder.order.service;import com.beeorder.order.dto.CreateOrderRequest;import com.beeorder.order.mapper.OrderItemMapper;import com.beeorder.order.mapper.OrderMapper;import com.beeorder.order.vo.OrderVO;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.dao.DuplicateKeyException;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;@Servicepublic class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; public OrderService(OrderMapper orderMapper, OrderItemMapper orderItemMapper) { this.orderMapper = orderMapper; this.orderItemMapper = orderItemMapper; } @Transactional public OrderVO createOrder(Long userId, CreateOrderRequest req) { try { var order = assemble(userId, req); // 生成 orderNo、算 totalAmount、置 CREATED orderMapper.insert(order); req.getItems().forEach(item -> orderItemMapper.insert(toSnapshot(order.getId(), item))); // 名称与单价在此冻结 return OrderConverter.toVO(order, orderItemMapper.selectByOrderId(order.getId())); } catch (DuplicateKeyException e) { // 撞在 uk_order_idem 上 = 重复提交,查出首次结果原样返回 var exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); log.info("重复下单,返回首次结果 orderNo={}", exist.getOrderNo()); return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId())); } } // assemble / toSnapshot 留给练习第二档实现}第五步,启动后连续提交两次同样的请求,观察第二次发生什么:
curl -s -X POST http://localhost:8080/api/v1/orders \ -H "Content-Type: application/json" \ -d '{"receiverName":"张三","receiverPhone":"13800138000","receiverAddr":"深圳市南山区科技园 1 号楼 802","idempotentKey":"demo-key-001","items":[{"productId":1001,"quantity":2}]}'预期响应(第一次,HTTP 200):
{ "code": 0, "message": "ok", "data": { "orderNo": "202610071530001234", "status": "CREATED", "totalAmount": 11998.00 }, "traceId": null, "timestamp": 1759827000000 }第六步,做两个实验:① 原样重放同一条 curl(idempotentKey 不变)——你会看到 Service 捕获 DuplicateKeyException 后返回同一个 orderNo,这就是幂等;② 把 receiverPhone 改成 "12345" 再发一次——请求根本进不到 Service,直接被 Bean Validation 挡成 400,code 为 4001,message 是 DTO 上写的那句「手机号格式不正确」。
验收清单:① 说得出第二次重放为什么不产生新订单,靠的是哪两行 DDL;② 说得出第六步的实验 ② 死在哪一站、谁把它翻译成 4001;③ 故意把 amount 的类型改成 Double 并重跑,解释为什么第十节要把这条写进团队约定。
每条只改一处,观察结论完全不同:
- 给
order表再加一条UNIQUE KEY uk_receiver_phone (receiver_phone),然后让两个不同用户用同一个收货电话下单。你会观察到:第二个请求报DuplicateKeyException——同一个异常类可能来自完全不同的索引。于是你不得不学会「先读索引名再判断性质」,这正是第十三节第一条的读法。 - 把
OrderQuery.getOffset()里的Math.min(size, 100)删掉,再用?size=100000请求一次。你会观察到:接口不再截断,响应时间从 21ms 变成秒级、返回体膨胀——第十一节沙盘第三档的数字就在你手上重现了。 - 给
CreateOrderRequest.Item.quantity加上@Min(1)后,用"quantity": 0请求。你会观察到:400 +code=4001,而items数组里那个嵌套对象的错误信息也照样能被报出来,前提是外层字段上有@Valid——去掉它,嵌套校验就静默失效,这个坑比报错本身更常见。
提示:做完第 2 条回头对照第五节实验二的 layer「越层调用代价」那一档,两者讲的是同一种「省一步、赔全局」。
给自己做一个「契约守卫」小工具,以后任何人改接口都要过它这一关。
需求:
- 扫描所有
@RestController,导出一份简版 OpenAPI(路径、方法、请求 DTO 字段与约束、响应 VO 字段) - 与仓库里冻结的
api-contract.json对比,发现删除字段、改字段类型、改必填性、改 HTTP 方法这四类破坏性变更就让构建失败 - 顺带校验两条本篇定的硬规则:金额字段必须是
BigDecimal(不许Double/double);响应 VO 不许出现password、deleted、version这类内部字段 - 输出一张报告:接口、变更类型、影响的调用方、建议的兼容做法(新增可选字段而非改名)
验收清单:① 手动把一个 VO 字段改名,构建应当失败并报出该接口;② 新增一个可选字段,构建通过;③ 把某个金额字段改成 Double,第二条规则应抓到它;④ 在 OrderVO 里临时加一个 userId,第三条规则应抓到它并指出「前端无需知道」。
不看上文,说出 BeeOrder 三处幂等点各自的幂等键与约束方式,并解释为什么「先查再插」不算幂等。
订单项为什么要冗余商品名与单价?如果只存 product_id,商品改名之后哪些环节会出问题?
联合索引 (user_id, status, create_time) 能服务哪三种查询、服务不了哪一种?用一句话讲清「最左前缀」。
金额为什么必须 DECIMAL + BigDecimal?给出一个具体的浮点算错的例子,并说清舍入应该在哪一层做。
接口契约的七个要素是哪七个?缺哪一个会让联调当天必然有人来找你?
领域在前表在后,会变的事实要快照,钱用 Decimal 时间用 UTC,唯一索引管幂等,信封管出口,分页必设上限。