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

19 KiB
原始文件 Blame 文件历史

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 7980 产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503,并订正 Swagger notes 里『乐观锁』的错误说法(接口路径/字段零增删) internal wx(GIT) 修改接口 deployed not_required not_required 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 状态,可重试,不当系统异常),已记。 2026-09-20 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

请求示例

POST /internal/product/1234567890123456789/deduct-stock?date=2026-10-01&count=2

(跨服务 Feign 调用,无请求体,参数走 Query。)

响应示例

{ "code": 200, "message": "成功", "data": null, "success": true }

空数据 / 降级响应

本接口无「空数据」/降级概念(成功恒返回 data: null;库存不足时不是"空数据"而是显式失败,见错误响应)。

错误响应

{ "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

请求示例

POST /internal/product/1234567890123456789/restore-stock?date=2026-10-01&count=2

响应示例

{ "code": 200, "message": "成功", "data": null, "success": true }

空数据 / 降级响应

该产品该日期无价格日历记录时静默跳过(不抛异常,ProductPriceCalendarMapper#restoreStock 内部 record == null 时直接 return,无任何写入)。恢复数量超过已扣数量时 sold 按 0 兜底,不会变负数,同样不报错。

错误响应

{ "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: #7980
  • PR: #8047
  • Merge commit: f72a7548c(合并后回填,backend_status 待部署后翻转)

联系人

  • 后端负责人: @wx