文件
hl-api-changelog/changelogs-v2/2026-09/20_7980_产品库存扣减恢复两写口补同名锁新增100503冲突码-修改接口-管理后台.md
T
2026-09-20 23:35:03 +08:00

292 行
19 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
---
schema: "hl-changelog/v2"
ticket: "7980"
title: "产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503,并订正 Swagger notes 里『乐观锁』的错误说法(接口路径/字段零增删)"
consumer: "internal"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "PR #8047 已 squash 合并 dev-v3(合并提交 f72a7548c)。2026-09-20 23:20 已部署测试服 hl-product-service-v2:滚动更新两实例(8183 先起、7 秒 UP,再滚 8083、7 秒 UP),部署前 COMMIT=4cbccc26b(停在 2026-09-19,落后 93 个提交且这些提交触及本服务),部署后 COMMIT=ba8aab3ab;git merge-base --is-ancestor f72a7548c ba8aab3ab 返回 ANCESTOR_YES,确认本次改动已在运行版本内。同轮顺带把 #8011 的用例修复(4c2ec76a2)一并带上。📌 订正草稿里的一处理由错误:本仓是 monorepo、所有合并都进 dev-v3,f72a7548c 当然落在 dev-v3 祖先链内;此前用 order-v3 的部署点 5d14bc524 做 --is-ancestor 得到 NO,原因只是「那时还没滚到」,不是「属于另一条分支的独立提交」——错误理由若留着,会让人去找一条并不存在的分支线。部署前已核对 4cbccc26b..origin/dev-v3 的 hl-common 改动为纯新增(VehicleCategoryNameDTO +16 行、一个测试 +51 行),无破坏性契约变更,故单滚 product-v2 不需要连消费方一起滚。🔴 **未做任何功能调用取证,这是有意为之**:两个端点是 /internal/product/** 且当前零调用方,既不经网关、也没有任何 Feign 声明指向它们;直接打端口调用会真实扣减/恢复库存,副作用落在共享测试服上由其他会话承担,且 restore 不保证是 deduct 的精确逆操作。互斥本身由单测 ProductStockLockNameGuardTest(按效果反查两处 name 非空且相等)覆盖,这是 name 契约类改动的恰当取证层级。100503 在本服务上未做任何触发尝试,不要读成「测过不会触发」。gateway_status=not_required:两个端点路径/方法零变化,且 /internal/** 按 HL 架构约定不暴露至公网。frontend_status=not_required:全仓 grep 确认无任何 @FeignClient 声明指向 deduct-stock/restore-stock,order-v3 的 ProductFeignClient javadoc 亦明写这些接口「待后续 Issue 按需添加」,不存在任何前端或其他服务的触达路径,故本次互斥修复目前是预防性的。consumer 字段填 internal:这两个端点不属于 admin/mp 任何一端的直接接口。mmg 前端复核 2026-09-20:维持 not_required。/internal/product/** 不经网关、前端不可达;全仓无 @FeignClient 指向 deduct-stock/restore-stock(order-v3 ProductFeignClient javadoc 自标「待后续 Issue 按需添加」),当前零跨服务调用方,纯预防性闸口补齐,admin 端零触达路径零改动。若未来有服务接入 Feign 调用,100503 处理责任在调用方(判 body code 非 HTTP 状态,可重试,不当系统异常),已记。"
updated_at: "2026-09-20"
base: "dev-v3"
---
# product-v2: 产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503
> **存放目录**:
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
>
> **服务**: hl-product-service-v2
> **PR**: #8047
> **Issue**: #7980(AC-8)
> **日期**: 2026-09-20
> **影响范围**: `/internal/product/{productId}/deduct-stock`、`/internal/product/{productId}/restore-stock` 两个跨服务 internal 端点(**当前零跨服务调用方**,见业务边界)
---
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- `ProductPriceCalendarService.deductStock` 与 `restoreStock` 的 `@Lock4j` 的 `keys` **逐字相同**,但**两处都没写 `name`**——lock4j 缺省 `name` 时把「包名+类名+方法名」拼进 Redis 键,两处因此**各持一把锁**,可以并发互相踩踏。而 Mapper 层的实现是 `select → 改 sold → updateById` 的读改写(本身不原子),正确性完全押在这把此前不生效的锁上。
- 本次给两处补上同一个显式 `name = "product:stock"`,互斥才真正生效。**没有新增接口、没有增删字段**,唯一契约变化是新增一个可能返回的失败码 **`100503`**,**HTTP 状态码仍是 200**。
- 🔴 **本次同时订正了 `InternalProductController:121` Swagger notes 里的错误说法**:改前写的是「乐观锁扣减价格日历库存」,但 `product_price_calendar` 表**无 `version` 列**、`ProductPriceCalendarDO` **无 `@Version`**,「乐观锁」这个说法是假的——真实机制是本次修复的这把分布式锁。前端/调用方若据旧 notes 认为这里有乐观锁重试语义,应以本文档为准撤销该认知。
- 🔴 **这把锁的覆盖边界(避免误读成"sold 并发已全面安全")**:同一行 `sold` 还有第三个写口 `ProductPriceCalendarMapper#batchUpsert`(管理端批量设价,经 `ProductPricingService`/`ProductSnapshotService` 调用),同样是 select→改 sold→updateById 的读改写,且**不取本锁**,表上无 `version`/CAS,唯一键 `uk_product_date_tier` 只约束行身份不约束 `sold`。所以「管理端批量设价 ∥ 扣库存/恢复库存」的丢更新依旧存在——那是另一个问题,本次这把锁挡不住,本 PR 未处理,不在本文档改动范围内。
- 🔴 **两个端点当前零跨服务调用方**:全仓 grep `*Feign*.java` 对 `deduct-stock`/`restore-stock` 零命中,没有任何其他微服务的 Feign 客户端声明指向这两个路径。补锁不是因为"现在正在出问题",而是补齐一条本该存在但一直缺失的互斥闸口——回归面可实证为零(不影响任何现存调用方),闸口本身仍要补齐,不能以"零调用方"为由不修。
---
## 一、背景(选填)
工单 #7980 AC-8 原文写的是"零调用方",实测更准确的说法是「**零跨服务调用方**」——`InternalProductController:127/137` → `InternalProductService:590/600` → `deductStock`/`restoreStock` 这条链路在 `origin/dev-v3` 上本身是通的(Controller→Service→Mapper 三层代码完整、可编译、可被同服务内其他代码或未来的 Feign 客户端调用),只是当前没有任何其他微服务声明了指向这两个路径的 `@FeignClient` 方法。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 扣减库存(内部) | POST | `/internal/product/{productId}/deduct-stock` | 新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
| 2 | 恢复库存(内部) | POST | `/internal/product/{productId}/restore-stock` | 新增可能返回的错误码 | 与 §1 共用同一把 `product:stock` 锁 |
---
## 三、接口详情
### 1. 扣减库存(内部) `POST /internal/product/{productId}/deduct-stock`
**VO**: `无 ReqVO(Query 参数直绑,无请求体) → Void`
#### 使用场景
供其他微服务(如 order 系)通过 Feign 跨服务扣减产品价格日历库存(**当前无实际调用方**,见「⚠️ 关键变化」)。底层是 `select → 改 sold → updateById` 的读改写,靠 `@Lock4j(name=product:stock)` 分布式锁串行化;本表无 `version` 列、非乐观锁(Swagger notes 已随本次改动订正);本端点不做幂等,幂等由调用方保证。本次改动不涉及入参/出参,只新增一条「被 §2 恢复库存占用同一把锁」的失败分支。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | Path | Long | ✅ | - | 产品 ID |
| date | Query | String | ✅ | yyyy-MM-dd | 日期 |
| count | Query | int | ✅ | `@Min(1)` | 扣减数量,至少为 1 |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data | null | 无返回数据,成功恒为 `null` |
#### 请求示例
```http
POST /internal/product/1234567890123456789/deduct-stock?date=2026-10-01&count=2
```
(跨服务 Feign 调用,无请求体,参数走 Query。)
#### 响应示例
```json
{ "code": 200, "message": "成功", "data": null, "success": true }
```
#### 空数据 / 降级响应
本接口无「空数据」/降级概念(成功恒返回 `data: null`;库存不足时不是"空数据"而是显式失败,见错误响应)。
#### 错误响应
```json
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
```
| code | 触发条件 |
|------|----------|
| 400 | 参数校验失败(`count < 1`) |
| 480201 | 该日期库存不足或未开放(`sold + count` 超过 `dailyStock`,或该产品该日期无价格日历记录),请重新选择出发日期 |
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §2 恢复库存共用同一把 `product:stock` 锁),**可重试** |
#### 业务边界
- 无 `@Idempotent`——本端点不做幂等,幂等由调用方(order)保证,本次改动未涉及此设计。
- 抢锁失败(100503)时该次请求未进入方法体,零写入——`sold` 列不会被部分更新。
- 库存判定只按 `tier_seq=1`(默认档位)查询(`selectByProductIdAndDate` 硬编码),多档位产品的其余档位库存不受本端点影响——这是既有实现的既有边界,非本次改动引入,本 PR 未处理。
- 该锁**只覆盖本方法与 §2 恢复库存**,不覆盖 `batchUpsert`(管理端批量设价),见「⚠️ 关键变化」。
- 老数据兼容:无字段变化。
---
### 2. 恢复库存(内部) `POST /internal/product/{productId}/restore-stock`
**VO**: `无 ReqVO(Query 参数直绑,无请求体) → Void`
#### 使用场景
供其他微服务在订单取消/过期时通过 Feign 跨服务恢复产品价格日历库存(**当前无实际调用方**,见「⚠️ 关键变化」)。底层同样是 `select → 改 sold → updateById` 的读改写,`sold` 不减到负数(`Math.max(sold-count, 0)`)。本次改动不涉及入参/出参,只新增一条「被 §1 扣减库存占用同一把锁」的失败分支。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | Path | Long | ✅ | - | 产品 ID |
| date | Query | String | ✅ | yyyy-MM-dd | 日期 |
| count | Query | int | ✅ | `@Min(1)` | 恢复数量,至少为 1 |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data | null | 无返回数据,成功恒为 `null` |
#### 请求示例
```http
POST /internal/product/1234567890123456789/restore-stock?date=2026-10-01&count=2
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "data": null, "success": true }
```
#### 空数据 / 降级响应
该产品该日期无价格日历记录时静默跳过(不抛异常,`ProductPriceCalendarMapper#restoreStock` 内部 `record == null` 时直接 return,无任何写入)。恢复数量超过已扣数量时 `sold` 按 0 兜底,不会变负数,同样不报错。
#### 错误响应
```json
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
```
| code | 触发条件 |
|------|----------|
| 400 | 参数校验失败(`count < 1`) |
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 扣减库存共用同一把 `product:stock` 锁),**可重试** |
#### 业务边界
- 无 `@Idempotent`,同 §1。
- 抢锁失败(100503)时零写入。
- 本方法本身不会因"库存记录不存在"或"恢复超过已扣数量"抛错,均静默处理(这是既有行为,非本次改动引入)。
- 老数据兼容:无字段变化。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
### 并发冲突(100503)的正确处理方式
两个端点本次开始**真正互斥**(同一 `productId+date` 维度):同一时刻只有一个能执行,其余排队等待,等待超过 3 秒(`acquireTimeout`,本组 `@Lock4j` 未显式设置,取 lock4j-core 注解默认值 3000ms)直接返回失败。
| 场景 | 响应 |
|------|------|
| ✅ 单个请求,无并发 | 200 + 正常业务结果 |
| ✅ 同 `productId+date` 两个请求先后到达,第二个在 3 秒内轮到锁 | 两个都 200(第二个排队等待) |
| ❌ 同 `productId+date` 两个请求并发,第二个等待超过 3 秒未抢到锁 | `{ "code": 100503, "message": "资源被占用,请稍后重试", "success": false }`,**HTTP 状态码仍是 200** |
| ✅ 不同 `productId` 或不同 `date` 并发 | 互不影响,各自独立加锁(锁键含 `productId` 与 `date`) |
**调用方(若未来接入)必须做的事**:判断响应体 `code === 100503`(不是判 HTTP 状态码),命中时按业务语义重试或提示上游;**不要**当作系统异常。
### Swagger notes 订正说明(供以此为契约的调用方核对)
`InternalProductController:121` 扣减库存端点的 Swagger notes 已从「乐观锁扣减价格日历库存」改为「实现是 select→改 sold→updateById 的读改写,靠 @Lock4j(name=product:stock) 分布式锁串行化,本表无 version 列、非乐观锁」。任何据旧 notes 编写的调用方文档/注释若沿用了"乐观锁"这一表述,应据本次改动订正。
---
## 五、数据库行为(涉及写操作时必写)
本次不改变任何一次**成功**写操作实际写入的行数或内容——两个端点各自原有的写入逻辑(`sold` 列增减)逐字节不变。变化的只是"谁能在同一时刻对同一 `productId+date` 执行写入":
| 维度 | 改动前 | 改动后 |
|------|--------|--------|
| 两个端点是否互斥(同 productId+date) | 否(各自持独立锁,可并发) | 是(同一把锁,串行执行) |
| 抢锁失败时是否有部分写入 | N/A(此前不会因锁而失败) | 否,零写入(`@Lock4j` 方法级环绕拦截,拿不到锁直接抛异常,不产生任何 SQL) |
| `batchUpsert`(管理端批量设价)与本组两端点的并发关系 | 不受本锁保护,仍可能丢更新 | 不变——本次改动未处理,见「⚠️ 关键变化」 |
---
## 六、边界行为
- 参数校验失败(`count<1`)→ 400
- 库存不足或未开放(仅 `deduct-stock`)→ 480201
- **抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入**
- `restore-stock` 对不存在的价格日历记录/超额恢复 → 静默跳过或按 0 兜底,不报错,与本次改动无关
- 老数据兼容:本次不涉及字段增删,存量数据无需迁移
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
本组两个接口均为纯数值/日期参数,无枚举字段,本节不适用。
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| (无)| 两个接口的请求参数、响应结构逐字节不变 | 同左 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 两个端点同 productId+date 并发时 | 各自持独立锁,可同时执行(读改写非原子,可能丢更新) | 同一把锁,串行执行,互斥真正生效 |
| 抢锁失败的响应 | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);本次改动未部署测试服,无实测触发频率数据 |
| `InternalProductController:121` Swagger notes | 「乐观锁扣减价格日历库存」(与表结构不符的错误描述) | 订正为读改写 + 分布式锁串行化的真实机制描述 |
| 请求/响应字段、既有业务错误码(480201) | 不变 | 不变 |
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**: 否——不改字段、不改既有错误码语义;新增的 100503 失败路径由于**当前零跨服务调用方**,对现存系统零影响面。
- **前端是否必须同步上线**: 不适用——两个端点没有任何前端或其他服务的直接调用方,本次改动不需要任何下游同步。若未来有服务接入 Feign 调用这两个端点,调用方需按「四、契约约束」处理 100503。
- **前端 workaround 清理点**: 无。
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**: `/internal/product/{productId}/deduct-stock`、`/internal/product/{productId}/restore-stock` 两个端点在**未来有并发调用方时**的失败分支;当前因零调用方,实际影响面为零
- **零影响**:
- `ProductPriceCalendarMapper#batchUpsert`(管理端批量设价,经 `ProductPricingService`/`ProductSnapshotService` 调用)——不取本锁,行为未变,仍可能与本组两端点产生 `sold` 丢更新(既有问题,本次未处理)
- 价格日历的其余读接口(按产品/月份/日期范围查询等)
- `InternalProductController` 其余端点(产品详情、简单报价、班期信息/报价、报名入团等)
- 两个端点的请求参数、响应结构
- 既有业务错误码 480201 的触发条件与文案
- hl-gateway 路由配置——`/internal/**` 本就不对公网暴露,本次未新增任何路由
---
## 八、测试环境已验证
**部署读数(2026-09-20 23:20)**:hl-product-service-v2 滚动更新两实例,部署前 `4cbccc26b`(停在 2026-09-19,落后 93 个提交)→ 部署后 `ba8aab3ab`,8183 与 8083 各 7 秒内起监听,`deploy-backend.sh` 报 `rolling deploy complete`。`git merge-base --is-ancestor f72a7548c ba8aab3ab` 返回 **ANCESTOR_YES**,本次改动确在运行版本内。
🔴 **本节到此为止,没有功能调用读数,这是有意为之而不是遗漏**:两个端点是 `/internal/product/**`、当前**零调用方**,既不经网关也无任何 Feign 声明指向;直接打端口调用会**真实扣减/恢复库存**,副作用落在共享测试服上由其他会话承担,且 `restore-stock` 不保证是 `deduct-stock` 的精确逆操作(不可靠地撤回)。互斥本身由单测 `ProductStockLockNameGuardTest` 覆盖(按效果反查两处 `name` 非空且相等),这是 `name` 契约类改动的恰当取证层级。
⚠️ **`100503` 在本服务上一次都没有触发过**——不是「测过、不会触发」,是**没测**。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-8)
- 关联 PR: [wx/HL#8047](https://git.1814.love:8443/wx/HL/pulls/8047)
## 关联 / 联系人
### 链接
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
- **PR**: [#8047](https://git.1814.love:8443/wx/HL/pulls/8047)
- **Merge commit**: [f72a7548c](https://git.1814.love:8443/wx/HL/commit/f72a7548c)(合并后回填,backend_status 待部署后翻转)
### 联系人
- **后端负责人**: @wx