diff --git a/changelogs-v2/2026-09/10_7316_团期全团需求汇总补权限码与在团口径-结算汇总已核单数纠正-修改接口-管理后台.md b/changelogs-v2/2026-09/10_7316_团期全团需求汇总补权限码与在团口径-结算汇总已核单数纠正-修改接口-管理后台.md index 4199a43d..627088bc 100644 --- a/changelogs-v2/2026-09/10_7316_团期全团需求汇总补权限码与在团口径-结算汇总已核单数纠正-修改接口-管理后台.md +++ b/changelogs-v2/2026-09/10_7316_团期全团需求汇总补权限码与在团口径-结算汇总已核单数纠正-修改接口-管理后台.md @@ -179,7 +179,7 @@ Authorization: Bearer <持 group-batch:view 的管理端 token> #### 使用场景 -团期看板「结算」Tab,团期管理员 / 财务查看整团核单进度(已核单户数 / 在团户数)与成本、毛利、共享成本、预支汇总。**本端点无权限码要求**(当前登录态能过网关即可,不校验 `group-batch:view`,与「## 1.全团需求汇总」不同,见「业务边界」)。 +团期看板「结算」Tab,团期管理员 / 财务查看整团核单进度(已核单户数 / 在团户数)与成本、毛利、共享成本、预支汇总。**⚠️ 2026-09-10 订正**:本篇发布时(`#7316`/PR #7407+#7408 合并时点)本端点确实**零判权**(当前登录态能过网关即可,不校验 `group-batch:view`,与「## 1.全团需求汇总」不同)——**这不是有意的设计,而是 `#7411` 发现并修复的权限缺口**(越权可读毛利 `subOrderTotalProfit`、团期预支 `groupAdvanceApproved`/`groupAdvancePending` 等敏感经营数据)。`#7411`(PR #7447,合并提交 `856ab69bf`)已给本端点补齐权限码 **`group-batch:finance:view`**,不持该码 → **589507**(`GROUP_BATCH_PERMISSION_DENIED`,HTTP 恒 200)。详见 `10_7411_团期核单共享成本三端点补判权-修改接口-管理后台.md`。 #### 入参字段表 @@ -256,7 +256,7 @@ Authorization: Bearer <任意已登录管理端 token,无需 group-batch:view> - 「已核单」判据只看 `SettlementSummaryRespVO.settled` 标志,不能判 `null`——`SettlementService.getSummary` 对查不到 `order_settlement_summary` 行的子订单返回的是 `settled=false` 的空壳 VO(仅 `orderId`/`advanceSummary` 有值),**从不返回 `null`**,这是 PR #7408 修复的唯一缺陷点。 - `totalActiveOrderCount` 与「## 1.全团需求汇总」的 `activeOrderCount` 同口径(在团 = 仅排除 `CANCELLED`,含 `COMPLETED`),两者用同一条 `OrderService#selectInGroupOrderIdsByGroupBatchId(groupBatchId)`。 -- 本端点**无权限码校验**,任何能过网关鉴权的后台账号都能调用;与 `requirement-summary` 的判权状态不同步(一个要 `group-batch:view`、一个不要),这是既有设计,本次未新增或移除该端点的权限要求。 +- **⚠️ 2026-09-10 订正**:本篇发布时本端点确实无权限码校验,任何能过网关鉴权的后台账号都能调用,与 `requirement-summary` 的判权状态不同步(一个要 `group-batch:view`、一个不要)——**这不是既有设计,是缺陷**:`#7316` 发布后被 `#7411` 发现并列为安全缺口(同 Controller 的写端点 `POST .../settlement/cost` 同样零判权,任意后台账号可写入团期成本)。`#7411`(PR #7447,合并提交 `856ab69bf`)已修复:本端点(`GET .../settlement/summary`)与 `GET .../settlement/cost` 现要求权限码 `group-batch:finance:view`;`POST .../settlement/cost` 要求 `group-batch:finance:advance`;不持有对应权限码 → 589507。详见 `10_7411_团期核单共享成本三端点补判权-修改接口-管理后台.md`。 - `subOrderTotalActualCost` / `subOrderTotalProfit` 只累加 `settled=true` 的子订单;未核单户是**跳过**不计入累加,不是计 `0` 累加(数学效果一致,但语义上是跳过)。 - `groupAdvanceApproved` / `groupAdvancePending` 只计 scope=`GROUP_BATCH` 的团期级预支,不含子订单级(子订单级预支已进各户核单报销单,整团层再算一次会重复扣回);`groupAdvanceApproved`/`groupAdvancePending` 均不计入 `grandTotalCost`(预支是资金拨付不是成本)。 - 团期不存在 → 589500,判定在 `groupBatchService.requireById` 完成。 @@ -380,7 +380,7 @@ Authorization: Bearer <任意已登录管理端 token,无需 group-batch:view> - 两个端点的请求参数、响应结构(字段名、类型、嵌套层级)本次完全不变,只变语义、取值口径与其中一个端点的鉴权。 - 团期需求确认 / 打回三个端点(`confirm` / `confirm-check` / `reject`)使用的权限码仍是 `group-batch:demand:confirm`,不受本次影响。 -- `settlement/summary` 本身**没有新增权限码**,与 `requirement-summary` 的判权状态不同步是既有设计,本次未改变。 +- `settlement/summary` 本身在 `#7316` 发布时**没有新增权限码**,本次(`#7316`)确实未改变其判权状态;**⚠️ 2026-09-10 订正**:但「与 `requirement-summary` 判权不同步」并非有意设计,而是 `#7411` 发现并修复的缺陷——`#7411` 已给 `settlement/summary`、`settlement/cost`(GET/POST)三端点补齐 `group-batch:finance:view`/`group-batch:finance:advance` 判权,详见 `10_7411_团期核单共享成本三端点补判权-修改接口-管理后台.md`。 - 无 Flyway、无表结构变更、无新端点、网关路由零改动,只涉及 `hl-order-service-v3` 单模块。 --- diff --git a/changelogs-v2/2026-09/10_7347_finalize团期住宿户级闸门-修改接口-管理后台.md b/changelogs-v2/2026-09/10_7347_finalize团期住宿户级闸门-修改接口-管理后台.md new file mode 100644 index 00000000..22d1deef --- /dev/null +++ b/changelogs-v2/2026-09/10_7347_finalize团期住宿户级闸门-修改接口-管理后台.md @@ -0,0 +1,274 @@ +--- +schema: "hl-changelog/v2" +ticket: "7347" +title: "整单核单 finalize 新增团期住宿户级闸门,未安排好住宿不允许结算(新增错误码 584130)" +consumer: "admin" +author: "wx(GIT)" +change_type: "修改接口" +backend_status: "deployed" +gateway_status: "not_required" +frontend_status: "pending" +frontend_owner: "mmg" +frontend_ref: "" +target_release: "" +verified_at: "" +status_note: "后端已实现并部署测试服(HEAD 18c1df126,即本单实现提交本身,PR #7433);存量统计SQL结果为0,全量生效不做灰度。前端唯一需要做的事是给 584130 加提示文案,展示后端 message 即可,不需要改请求/响应结构。" +updated_at: "2026-09-10" +base: "dev-v3" +--- + +# 整单核单 finalize 新增团期住宿户级闸门(修改接口) + +> **服务**: hl-order-service-v3 +> **Issue**: [#7347](https://git.1814.love:8443/wx/HL/issues/7347) +> **日期**: 2026-09-10 +> **影响范围**: 既有端点 `POST /v3/admin/order/{orderId}/settlement/finalize`(完成核单)的**错误码集合**扩大;不新增/不删除端点,请求体、成功响应体结构、方法、路径、网关路由**一字未改** + +--- + +## ⚠️ 关键变化 + +1. **团期子订单完成核单(finalize)新增一道硬阻断**:该订单需要配房(`needsHotel=true`)但其住宿需求还没被房务推到 `DONE` 时,finalize 直接拒绝,新增业务错误码 **`584130`**。改前这种情况能直接结算通过(尤其是"一条住宿派生行都没有"的团期子订单,改前完全不受阻拦)。 +2. **HTTP 状态码恒为 200**,闸门以 `Result.code = 584130` 的业务错误体返回,前端必须判 `code`,不能按 HTTP 状态识别。 +3. 免房户(`needsHotel=false`)与非团期订单(`productBatchId` 为空)**完全不受影响,行为与改前逐字节一致**。 +4. 管理者已对测试库执行存量统计 SQL,结果为 **0**(团期子订单里没有一单会被新闸拦住),因此**全量生效,不做灰度**,没有需要提前处理的存量数据。 +5. 前端需要做的唯一事情:给 `584130` 加提示文案,见下方"前端提示文案建议"。不涉及任何请求/响应字段改动。 + +--- + +## 一、背景 + +团期房务批次(`#7322`–`#7328`)把住宿的**行级**确认接进了核单:某户某晚没分平,那一行就不能被确认(`#7327` 的 584129)。但**整单核单完成(finalize)此前对住宿没有任何硬性闸门**——一个团期子订单哪怕住宿完全没安排好,只要没有已录入的住宿行,照样能走完 finalize 结算掉(既有的住宿检查落在 `SettlementCategoryCheckService`,条件是 `rowCount > 0`,0 行时天然放行)。 + +本单在 `performSubmitBlockingChecks`(既有 finalize 阻断检查方法)里新增一道**户级**闸门:只要该团期子订单还需要配房,就必须等房务把户级住宿需求推到 `DONE` 才允许结算。闸门用户级谓词而不是团级 `order_group_batch.hotel_ready`——团级标志只要有一户没推平就是 false,会把整团所有户的结算一起冻住,违反"不让一户卡整团"的既有原则;户级谓词与房务侧完成判定 `finalizeHotelRequirementDone` 写的是同一个字段,两侧口径天然对齐。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|---|---|---|---|---| +| 1 | 完成核单(finalize) | POST | `/v3/admin/order/{orderId}/settlement/finalize` | 错误码集合扩大 | 新增可能返回 `584130`;请求体(无)、成功响应体结构、方法、路径均未改;网关路由零改动 | + +--- + +## 三、接口详情 + +### 1. 完成核单 `POST /v3/admin/order/{orderId}/settlement/finalize` + +**VO**: `无请求体(Path 参数 orderId)→ Result`(成功响应结构本单未改,字段集合不重复列出) + +#### 使用场景 + +核单员在核单页点击"完成核单",前端调用本端点。团期子订单在此新增一道住宿完成校验;本节判定入口与触发条件表见下方"业务边界"前的说明表。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---|---| +| `orderId` | Path | Long | 是 | 大于 0 | 订单 ID;本单未改 | + +**判定入口与触发条件**(前端可直接照此做提示判定表):新闸位于 `SettlementService.assertGroupHotelReadyForFinalize(OrderInfo)`,在既有的司机结算完成检查之后、`CASH_PAID` 缺凭证软预警之前触发(硬阻断,非软预警): + +| 条件 | 判定结果 | +|---|---| +| `productBatchId == null`(非团期订单) | **不受本单影响**,行为与改前完全一致 | +| `productBatchId != null` 且 `needsHotel != true`(团期免房户) | **放行**,行为与改前一致 | +| `productBatchId != null` 且 `needsHotel == true`,该户**最新一条住宿需求 `status == DONE`** | **放行** | +| `productBatchId != null` 且 `needsHotel == true`,住宿需求**不存在**(一条派生行都没有,改前的漏洞场景) | 抛 **`584130`** | +| `productBatchId != null` 且 `needsHotel == true`,住宿需求存在但 `status` 为 `PENDING` / `PROCESSING` / `PENDING_REVIEW` / `REJECTED_TO_CONSULTANT` / `REJECTED_TO_ADMIN`(即非 `DONE`) | 抛 **`584130`** | +| 该户住宿派生行已被撤销转为 `GROUP_BATCH_PLAN_REVOKED`,但住宿需求本身仍非 `DONE` | 抛 **`584130`**(`REVOKED` 行不构成"已安排好"的证据) | + +判定只读户级住宿需求的 `status` 字段,**不看有没有派生行、也不解析日期判断"是否自助订房"**——这些口径统一由房务侧的完成判定负责写 `DONE`,本闸只读结果。 + +#### 出参 + +成功响应结构本单**未改**,`Result` 字段集合与改前完全一致(不重复列出全部字段,仅摘录与本单相关的部分): + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.orderId` | Long | 订单 ID;本单未改 | +| `data.finalSnapshotStatus` | String | 核单终态快照状态;本单未改 | +| `data.warnings` | `List` | 软预警列表(如 `CASH_PAID` 缺凭证);本单未改,`584130` 不进这个列表,而是走错误响应 | + +#### 请求示例 + +```http +POST /v3/admin/order/1000123/settlement/finalize +``` + +(无请求体,仅 Path 参数 `orderId`;本单未改请求形态) + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "orderId": 1000123, + "finalSnapshotStatus": "CONFIRMED", + "warnings": [] + } +} +``` + +(成功路径响应结构本单未改,仅摘录关键字段) + +#### 空数据 / 降级响应 + +不适用:本闸不产生空数据/降级语义,判定只有"放行"或"抛 584130"两种结果。 + +#### 错误响应 + +```json +{ + "code": 584130, + "message": "团期子订单 GB202609090001 的住宿尚未安排完成(住宿需求当前状态:PROCESSING),请等房务配房完成后再提交核单", + "success": false, + "data": null +} +``` + +message 模板(`SettlementErrorCode.SETTLEMENT_GROUP_HOTEL_NOT_READY`,`SettlementErrorCode.java:339-342`)为: + +``` +团期子订单 {0} 的住宿尚未安排完成(住宿需求当前状态:{1}),请等房务配房完成后再提交核单 +``` + +两个占位符:`{0}` = 订单号(`orderNo`),`{1}` = 住宿需求当前状态。**住宿需求行完全不存在时**,`{1}` 填固定文案 **`未提交住宿需求`**(常量 `SettlementService.GROUP_HOTEL_REQUIREMENT_ABSENT`,逐字抄自源码,不是状态枚举值),而不是某个状态码。 + +#### 业务边界 + +- 闸门只对 `productBatchId != null`(团期子订单)生效,核心订单的房控闸是另一套逻辑(`OrderService.needsHotel != true → 视为房控通过`),本单不动它。 +- 闸门读取的是"该户最新一条住宿需求"(`RequirementService.getLatestHotelRequirementByOrderId`),走既有 Service 公共只读方法,未直连 Mapper。 +- 与既有"已录入住宿行全部 CONFIRMED"的行级检查(`SettlementCategoryCheckService`)并列不重叠:旧检查看的是已录入行的确认状态(行级),新闸看的是住宿需求 `status`(户级),两者判定顺序上新闸更早触发(`performSubmitBlockingChecks` 早于 `SettlementReportFlowService.prepareFinalizationInCurrentTransaction`)。 + +--- + +## 四、契约约束与正确调用方式 + +- **必须按响应体 `code == 584130` 识别,不能按 HTTP 状态码识别**——HTTP 恒为 200。 +- 建议直接展示后端 `message`,其中已包含订单号与住宿需求当前状态,无需前端自行拼接。 +- "该团期子订单是否可以完成核单"以 finalize 实际返回结果为准,前端**不应该**通过"是否存在住宿派生行"自行推断能否结算——这正是改前的漏洞场景(0 行时误以为可以结算)。 + +### 切换状态时的必要动作 + +无。本单不涉及任何需要调用方额外置空/切换的字段,闸门完全由后端根据现有数据判定。 + +--- + +## 五、数据库行为 + +- `584130` 在阻断检查阶段抛出,属于既有 finalize 阻断检查的一部分,抛出后**不产生任何写入**(不写核单快照、不写车辆冻结、不写日志表)。 +- 判定只读 `order_main`(`productBatchId`/`needsHotel`)与住宿需求表(`status`),**无表结构变更、无 Flyway**。 + +--- + +## 六、边界行为 + +- 团期免房户(`needsHotel=false`):放行,不查住宿需求。 +- 团期需房户,住宿需求 `status=DONE`:放行。 +- 团期需房户,住宿需求缺失(零派生行):拒绝,`584130`,`message` 中状态位为"未提交住宿需求"。 +- 团期需房户,住宿需求非 DONE(`PENDING`/`PROCESSING`/`PENDING_REVIEW`/`REJECTED_TO_CONSULTANT`/`REJECTED_TO_ADMIN`):拒绝,`584130`,`message` 中状态位为该实际状态值。 +- 该户住宿派生行已转 `GROUP_BATCH_PLAN_REVOKED`,但需求 `status` 未到 `DONE`:仍拒绝,`584130`(`REVOKED` 行不算完成证据)。 +- 非团期订单:完全不受影响,无论住宿情况如何都走改前逻辑。 +- **全自订户**(每一晚都自助订房)的 `needsHotel` 依然为 `true`,其住宿需求需先由房务侧完成判定推到 `DONE` 才能通过本闸;这条口径由另一单负责写 `DONE`,本单只读结果、不重复判定。 + +--- + +## 六.5、枚举 / 数据字典 + +### 新增错误码(`SettlementErrorCode`,`hl-order-service-v3/src/main/java/com/hulalv/order/settlement/errorcode/SettlementErrorCode.java:339-342`) + +| code | 常量名 | message 模板 | 触发条件 | +|---|---|---|---| +| `584130` | `SETTLEMENT_GROUP_HOTEL_NOT_READY` | `团期子订单 {0} 的住宿尚未安排完成(住宿需求当前状态:{1}),请等房务配房完成后再提交核单` | 团期子订单需要配房,且住宿需求当前状态不是 `DONE`(含需求缺失) | + +### 住宿需求状态枚举(`RequirementStatus`,本单只读不改,供理解 `{1}` 占位取值) + +| value | label | 是否放行本闸 | +|---|---|---| +| `PENDING` | 待房务配 | 否 | +| `PROCESSING` | 配房中 | 否 | +| `DONE` | 配房完成 | 是(唯一放行值) | +| `PENDING_REVIEW` | 待审核 | 否 | +| `REJECTED_TO_CONSULTANT` | 驳回 | 否 | +| `REJECTED_TO_ADMIN` | 驳回 | 否 | +| (需求行不存在) | —— | 否,`message` 状态位显示"未提交住宿需求" | + +### 前端提示文案建议 + +- 直接展示 `message` 原文即可(已含订单号 + 当前状态),无需二次包装。 +- 若需要更醒目的引导,可在 `message` 之外追加一句操作指引,例如:"请前往房务模块查看该团期订单的住宿安排进度,配房完成后再回来提交核单。" +- 不建议把 `584130` 与其他 40xxxx/58xxxx 段错误码合并成同一个通用提示——该码语义明确(住宿未完成),合并展示会丢失"该去催房务"这条关键信息。 + +--- + +## 六.6、修改前后对比 + +### 行为级对比 + +| 场景 | 改前 | 改后 | +|---|---|---| +| 团期需房户,一条住宿派生行都没有 | **可以直接结算通过**(改前存在的漏洞) | 拒绝,`584130` | +| 团期需房户,住宿需求非 `DONE`(有派生行但未分平) | 可能继续核单(仅受行级 `CONFIRMED` 检查约束,0 行时不受约束) | 拒绝,`584130` | +| 团期需房户,住宿需求 `DONE` | 可核单 | 行为不变,仍可核单 | +| 团期免房户 | 可核单 | 行为不变 | +| 非团期订单 | 可核单(受核心订单自己的房控闸约束) | 行为不变,核心房控闸逻辑本单未动 | +| 该户住宿派生行已被撤销为 `GROUP_BATCH_PLAN_REVOKED` | 若无行级检查约束可能通过 | 拒绝,`584130`(除非需求 `status` 已是 `DONE`) | + +### 字段级对比 + +无字段变化。请求体(无)、成功响应体 `SettlementSubmitRespVO` 的字段集合均未改动,仅错误响应的 `code` 取值范围新增了 `584130`。 + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**:否。端点方法/路径/请求体/成功响应体结构均未变化;免房户、`DONE` 户、非团期订单的行为与改前逐字节一致。 +- **前端是否必须同步上线**:需要新增 `584130` 的提示文案,否则该错误会被前端当成未知错误码兜底展示(用户体验较差,但不会导致请求失败或数据错误)。 +- **前端 workaround 清理点**:如果前端此前依赖"住宿派生行是否存在"来判断该团期子订单是否可结算,需要清理——这个判断口径本身就是改前的漏洞,不应再使用;一律以 finalize 实际返回结果为准。 + +--- + +## 七、不影响范围 + +- **仅影响**:`POST /v3/admin/order/{orderId}/settlement/finalize` 端点在团期子订单(`productBatchId != null`)且需要配房(`needsHotel=true`)时的错误码集合。 +- **零影响**: + - 无新增/修改/删除任何其他端点;网关路由零改动。 + - 免房户(`needsHotel=false`)与非团期订单(`productBatchId` 为空)的 finalize 行为与改前完全一致。 + - 成功响应体 `SettlementSubmitRespVO` 字段集合未变。 + - 不读取团级 `order_group_batch.hotel_ready` 字段,不会出现"一户卡整团"的情况。 + - 不涉及表结构变更、Flyway、数据迁移。 + - 既有的住宿"已录入行全部 CONFIRMED"行级检查(`SettlementCategoryCheckService`)逻辑未改,新闸与它并列而非替代。 + +--- + +## 八、测试环境已验证 + +- **存量影响评估**:管理者已对测试库执行存量统计 SQL(统计"团期子订单中会被新闸拦住"的数量),**结果为 0**——没有任何存量团期子订单处于会被新闸拦截的状态。因此本单**全量生效,不做灰度**,无需推平存量数据。 +- **部署状态**:测试服已部署 HEAD `18c1df126`,该 HEAD 即本单的实现提交本身(PR #7433)。 +- **网关验证**:端点路径/方法未变,无需新增网关路由配置;沿用既有 `/v3/admin/order/**` 路由规则。 +- **兼容性结论**:端点结构不变,仅扩展业务错误码集合,前端只需新增对 `584130` 的分支处理。 + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#7347](https://git.1814.love:8443/wx/HL/issues/7347) +- 前置/关联依赖:`#7327`(团期住宿行级确认,584129 与本单 584130 相邻但语义不同)、`#7325`(户级住宿完成判定,本单读取其写入的 `status` 字段) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#7347](https://git.1814.love:8443/wx/HL/issues/7347) +- **PR**: 尚未创建(合并后回填 [#N](https://git.1814.love:8443/wx/HL/pulls/N)) +- **Merge commit**: 尚未产生(合并后回填 [https://git.1814.love:8443/wx/HL/commit/sha](https://git.1814.love:8443/wx/HL/commit/sha)) + +### 联系人 + +- **后端负责人**: @wx +- **前端负责人**: @mmg diff --git a/changelogs-v2/2026-09/10_7390_双写扣减孤儿持有行自动收敛-修复-管理后台.md b/changelogs-v2/2026-09/10_7390_双写扣减孤儿持有行自动收敛-修复-管理后台.md new file mode 100644 index 00000000..e2d48966 --- /dev/null +++ b/changelogs-v2/2026-09/10_7390_双写扣减孤儿持有行自动收敛-修复-管理后台.md @@ -0,0 +1,219 @@ +--- +schema: "hl-changelog/v2" +ticket: "7390" +title: "双写扣减孤儿持有行自动收敛;重新提交配房不再被无主持有行恒锁 808902" +consumer: "admin" +author: "wx(GIT)" +change_type: "修复" +backend_status: "deployed" +gateway_status: "not_required" +frontend_status: "pending" +frontend_owner: "mmg" +frontend_ref: "" +target_release: "" +verified_at: "" +status_note: "后端已实现并部署测试服(HEAD 18c1df126,实现提交 f6e5c9356)。本单是定时Job行为增强,不改任何HTTP接口/字段/VO,前端无需改代码;唯一需要知道的是改前对同一槽位重复提交恒抛808902的问题现在会在宽限窗口(默认60分钟)后自动解开。⚠️本单不修根因,详见正文「本单不修根因」节。" +updated_at: "2026-09-10" +base: "dev-v3" +--- + +# 双写扣减孤儿持有行自动收敛(修复) + +> **服务**: hl-order-service-v3(house 域 `DualDeductionReconcileJob` / `HouseDualDeductionService` / `HouseDualDeductionLogMapper`) +> **Issue**: [#7390](https://git.1814.love:8443/wx/HL/issues/7390) +> **日期**: 2026-09-10 +> **影响范围**: 定时补偿 Job `DualDeductionReconcileJob` 的行为扩展;**不涉及任何对外 HTTP 接口**,无新增/修改/删除端点,无请求/响应结构变化,无网关路由变化 + +--- + +## ⚠️ 关键变化 + +1. **前端/客服唯一需要知道的一条**:改前,对同一 `(order, day, roomType, hotel)` 槽位重新提交配房,若该槽位被一种"扣减已成功但配房未落库"的孤儿持有行占住,会**恒抛 `808902`**("幂等键已使用"),没有任何自动或人工手段能解开,客人房订不进去。改后,这类孤儿行会被 Job 在**宽限窗口(默认 60 分钟)**后自动收敛(判为无主则释放),之后重新提交配房能**正常走通并真实扣减库存**。 +2. **本单不修根因**——见下方独立小节,这句话不能被读成"孤儿行的产生原因已经修复"。 +3. 本单**不改任何 HTTP 接口**,纯粹是后台定时 Job(`DualDeductionReconcileJob`)的行为扩展:新增第二段"无主持有行收敛"扫描 + 第三段"废弃扣减尝试"收口,与原有的第一段 `PENDING` 释放扫描并列,互不合并、互不干扰。原有第一段逻辑**一字未改**。 +4. 新增两条安全守卫(详见"六、边界行为"):**指纹不符只报警不释放**、**按来源分支回查 owner(含团期持有行)**——防止误判导致正在使用的库存被错误放掉(少卖变超卖)。 +5. 新增配置项 `house.dual-deduction.orphan-grace-minutes`(默认 **60** 分钟),需在 Nacos 确认已生效(沿用默认值即可,无需人工干预)。 + +--- + +## ⛔ 本单不修根因 + +`HouseAssignmentService.submitPersist`(配房提交链路的持久化阶段)**仍然在 try 块之外**(`HouseAssignmentService.java:576-600` 为扣减 try 块,persist 调用点在 `:609` 的 `self.submitPersist(...)`,落在 try 之外),也就是说:扣减库存全部成功后,若持久化配房行这一步因为并发/DB约束/状态机等原因失败回滚,扣减日志行仍会停在 `(resource_success=1, rollback_status=NONE)` 且没有任何配房行与它对应——**孤儿行会继续产生,产生速率与改前完全一样**。 + +本单只是让这类已经产生的孤儿行,在经过宽限窗口后能被 Job **自动收敛**(判为无主则释放槽位),从而解开重新提交的死锁。**不得把本单理解为"孤儿行的产生原因已经修复"**——根因修复(把 `submitPersist` 纳入事务边界)是独立后续单,本单范围之外。 + +--- + +## 一、背景 + +`house_dual_deduction_log` 表的一行记的是"一次扣减占了某个(订单/日期/房型/酒店)槽位"的库存持有关系。配房提交链路先逐条扣减库存(全部成功后),**随后**才调用 `submitPersist` 落配房行,而这个持久化调用不在 try 块内。因此当 `submitPersist` 事务回滚时,已扣减的日志行会留在 `(resource_success=1, rollback_status=NONE)` 这个状态,其 `assignment_id` 指向一个从未 insert 成功的预生成雪花 id——**远端库存被真实占用,本地却没有任何配房行对应它**。 + +改前唯一的补偿扫描 `selectPendingForReconcile` 只扫 `rollback_status='PENDING'`,`(1, NONE)` 这个状态**两类候选都不满足**,Job 永远不会碰它。同时 `(1, NONE)` 也是正常持有行的合法状态,所以不能简单地"见到就释放"——必须先证明它无主。而这类孤儿行还会把对应槽位锁死:再次提交同一(order, day, roomType, hotel)会命中这条孤儿行,因 owner 不符(或数量不符)而抛 **`808902`**(幂等键已使用),槽位永久无法重新使用,方向是**少卖 + 功能阻断**(房卖不出去,客人也重提不了),不是超卖。 + +本单在 `DualDeductionReconcileJob` 里新增两段并列扫描(与原有 `PENDING` 扫描互不合并): + +- **第二段**:扫 `(resource_success=1, rollback_status=NONE)` 且超过宽限窗口的行,按来源分支回查 owner,证明无主才释放。 +- **第三段**:扫 `(resource_success=0, rollback_status=NONE)` 且租约过期的废弃扣减尝试,只放弃、不采用,避免与业务方同键重试产生竞态。 + +--- + +## 二、变更接口清单 + +不适用。本单不新增/修改/删除任何对外 HTTP 接口,没有严格意义上的"变更接口"。受影响的是既有定时 Job `DualDeductionReconcileJob` 的内部扫描逻辑,其触发入口(`/v3/internal/jobs/dual-deduction-reconcile/scheduled`、`/v3/internal/jobs/dual-deduction-reconcile/daily`)本身的方法/路径/请求响应结构均未改动。 + +--- + +## 三、接口详情 + +不适用:本单不涉及任何对外 HTTP 接口,故无"变更接口清单/逐接口 VO 契约"可填。以下按"行为变更点"分两小节说明 Job 内部逻辑变化。 + +本单不涉及对外 VO 契约,不适用模板逐接口 VO + 入参/出参字段表结构。按"行为变更点"分两小节说明。 + +### 1. 新增第二段扫描:无主持有行自动收敛 + +**涉及方法**:`DualDeductionReconcileJob.reconcileOrphanHolding()`(私有方法,被 `doReconcile()` 在原有 `PENDING` 扫描之后调用),无独立对外 HTTP 签名。 + +#### 使用场景 + +Job 定频(每 5 分钟)与每日凌晨全量兜底两个既有触发点里,新增本段扫描。扫描 `resource_success=1 AND rollback_status='NONE' AND update_time < now - orphan-grace-minutes` 的候选行(游标分页,单轮最多翻 `orphan-max-pages`(默认 5)页,每页 200 条)。 + +#### 处理逻辑 + +逐条按 `source_type` 分支回查 owner: + +| `source_type` | 回查表 | owner 不存在(含已软删) | owner 存活且指纹一致 | owner 存活但指纹不符 / 不再持有库存 | +|---|---|---|---|---| +| 空 或 `HOUSE_ASSIGNMENT`(逐单配房) | `house_hotel_assignment`(经 `HouseAssignmentService.findDeductionOwner`,不直连 Mapper) | **释放**(调既有 `restoreByLogId`) | 跳过,不改任何字段 | **只报警,不释放** | +| `GROUP_BATCH_PLAN`(团期计划) | 团期计划表(经 `GroupBatchRoomPlanService.findDeductionOwner`,不直连 Mapper) | **释放** | 跳过,不改任何字段 | **只报警,不释放** | +| 未知取值,或 `assignment_id` 为空 | —— | **永不释放**,只报警计数 | —— | —— | + +指纹 = `(hotel_id, room_type_id, stay_date, room_count)`,不含价格与版本号。 + +释放动作复用既有的 `restoreByLogId(id, round, "orphan-reconcile")`,走既有 `markReleasePending` CAS + `release-{logId}-{round}` 账本键,**没有新造释放路径**。 + +#### 空数据 / 降级响应 + +无候选(本轮没有超过宽限窗口的孤儿):本段直接跳过,只打一条聚合日志,不产生任何写入。 + +#### 错误响应 + +不适用:本方法内部 try/catch 单条隔离,单行处理异常只记 `releaseFailed` 计数与 error 日志,不向上抛出,不影响其余候选行与其它两段扫描。 + +#### 业务边界 + +- **游标分页**(Redis key `house:dual-deduct:orphan-cursor`,值 `{updateTime}|{id}`,**TTL 24 小时**):因为正常持有行被跳过时**不改任何字段(含 `update_time`)**,若不用游标,每轮都会卡在同一批最老的正常持有行上,后方真正的孤儿永远轮不到。取回不足一页视为扫到表尾,游标清除,下一轮从头重扫。 +- 全程不修改正常持有行的任何字段,`owner 存活` 分支是纯读,零副作用。 +- 逐行"owner 存活,跳过"日志走 `DEBUG` 级别(候选集是全部超过 60 分钟的正常持有行,量级可能很大,打 INFO 会刷屏),聚合统计走 `INFO`:`[house-orphan] 完成 candidates= released= releasePending= releaseFailed= ownerAlive= fingerprintMismatch= unresolved= pages= cursor=X→Y`。 + +### 2. 新增第三段扫描:废弃扣减尝试收口 + +**涉及方法**:`DualDeductionReconcileJob.replayAbandonedAttempts()` + `HouseDualDeductionService.replayAbandonedAttempt(logId, expectedRound)`。 + +#### 使用场景 + +扫 `(resource_success=0, rollback_status='NONE')` 且租约(`house.dual-deduction.deduct-attempt-lease-minutes`,默认 10 分钟,与扣减侧认领租约复用同一配置)过期的行——这类行代表一次扣减尝试因超时/kill等原因悬而未决,且没有被同键业务重试接管。 + +#### 处理逻辑 + +只放弃、不采用:重新领取租约后按结果分支收口成 `DONE`(成功则转入第二段同款释放路径的准备状态,交第一段收口)或 `FAILED`(失败重试计数)。**Job 的任何分支都不产出 `(1, NONE)`,也不把行交给第二段**——这类行唯一合法的"被采用"路径是同键业务重试,Job 与业务重试抢的是同一把租约,Job 只能放弃,不能抢先采用,否则会与业务方产生竞态导致资源误判。 + +#### 业务边界 + +- 若 Job 领到租约后、写入放弃意图之前,同键业务重试抢先接管,Job 的合并 CAS 会返回 0,此时**立即停止、零后续写**(不改 `assignment_id`、不标 `DONE`、不调释放),只记 `replayLostLease` 计数与警告日志,避免释放掉业务刚刚接管的持有。 + +--- + +## 四、契约约束与正确调用方式 + +本单不涉及任何入参校验规则变化,也没有新的正确/错误 payload 需要记录(本单无 HTTP 契约)。唯一需要前端/客服知道的行为规则是: + +**同一 `(order, day, roomType, hotel)` 槽位如果重新提交仍然收到 `808902`,先检查提交时间与上一次失败尝试的时间差是否已超过宽限窗口(默认 60 分钟)**——若未超过,属预期内的正常等待,无需上报为 bug;若已远超过窗口仍持续 808902,才需要按异常上报排查。 + +### 切换状态时的必要动作 + +无。本单不涉及任何需要调用方显式置空/切换的字段,全部由 Job 后台异步处理。 + +--- + +## 五、数据库行为 + +- 本单无 Flyway、无表结构变更、无字段增删(`house_dual_deduction_log.source_type` 列由前置单 `#7323` 已经带上,本单只是消费它,不新增列)。 +- 判为无主的行会被 Job 更新为 `DONE`(走既有 `restoreByLogId` → `markReleasePending`/`markRolledBackCas` 链路),对应的资源侧库存账本会多出一条 `release-{logId}-{round}` 记录(幂等,不会重复释放)。 +- 判为"owner 存活但指纹不符"的行**零写入**,只记日志与内存计数。 + +--- + +## 六、边界行为 + +- **指纹不符只报警不释放**:owner 存活但 `(hotel_id, room_type_id, stay_date, room_count)` 与日志行不一致(改期/调整链路的残留形态),Job 只 `log.warn` 计数,**不动库存、不改状态**——自动释放会把业务正在使用的库存放掉,方向从少卖变成超卖,比原问题更糟。 +- **团期持有行按来源分支回查,而不是与逐单配房共用同一张 owner 表**:`source_type='GROUP_BATCH_PLAN'` 的行经 `GroupBatchRoomPlanService.findDeductionOwner` 回查团期计划表,而不是走 `HouseAssignmentService`(后者查不到团期计划,若误用会把所有存活的团期持有行都误判成"无主"进而错误释放)。判定逻辑与逐单配房一致:owner 存活且指纹一致则跳过,owner 已软删/不存在才释放。 +- 宽限窗口内的候选(`update_time` 未超过 `orphan-grace-minutes`)本轮不处理,等下一轮或超过窗口后再处理。 +- 三段扫描互不干扰:某一段抛异常只影响该段本轮结果,另外两段照常执行(`runSegment` 逐段 try/catch 隔离)。 +- 第一段(原有 `PENDING` 扫描)逻辑**一字未改**。 + +--- + +## 六.5、枚举 / 数据字典 + +### 新增配置项 + +| 配置 key | 默认值 | 说明 | +|---|---|---| +| `house.dual-deduction.orphan-grace-minutes` | **60**(分钟) | 第二段孤儿判据的宽限窗口:扣减成功到配房持久化在途的最长容忍时长,超过才纳入无主候选扫描 | +| `house.dual-deduction.orphan-max-pages` | 5 | 第二段单轮最多翻几页(每页 200 条),保证整轮在锁 TTL(5 分钟)内结束,翻不完的下一轮凭 Redis 游标接力 | +| `house.dual-deduction.deduct-attempt-lease-minutes` | 10 | 第三段候选租约窗口,与扣减侧认领租约复用同一配置项(非新增,本单新用途) | + +以上三项均已在代码里给了默认值兜底(`@Value("${...:默认值}")`),未在 Nacos 显式配置时按默认值生效。 + +### Job 统计口径(用于巡检/排障,前端不感知但客服排障可能用到) + +| 计数字段 | 含义 | +|---|---| +| `candidates` | 第二段本轮扫到的候选总数 | +| `released` | 判为无主并成功释放的数量 | +| `releasePending` | 判为无主但释放未最终确认(转 `PENDING` 交第一段兜底)的数量 | +| `releaseFailed` | 释放动作本身异常的数量 | +| `ownerAlive` | owner 存活且指纹一致,跳过的数量(正常持有行) | +| `fingerprintMismatch` | owner 存活但指纹不符,只报警未释放的数量 | +| `unresolved` | 来源判不出/`assignment_id` 为空,永不释放的数量 | + +--- + +## 七、不影响范围 + +- **仅影响**:`DualDeductionReconcileJob` 的内部扫描行为(新增两段,原有第一段不变)。 +- **零影响**: + - 无新增/修改/删除任何对外 HTTP 接口,网关路由零改动。 + - 无 Flyway、无表结构变更。 + - 正常持有行(owner 存活且指纹一致)的任何字段(含 `update_time`)不会被本单的扫描修改。 + - 原有 `PENDING` 释放扫描(第一段)逻辑一字未改。 + - 不修复 `submitPersist` 在 try 块外的根因,孤儿行仍会以改前同样的速率继续产生(见上方"本单不修根因"节)。 + +--- + +## 八、测试环境已验证 + +- **部署状态**:测试服已部署 HEAD `18c1df126`,该 HEAD 已包含本单实现提交 `f6e5c9356`(PR #7426,`git merge-base --is-ancestor f6e5c9356 18c1df126` 为真)。 +- **前端可感知的核心行为**:同一 `(order, day, roomType, hotel)` 槽位被孤儿行占住后,改前重新提交恒抛 `808902` 且无出口;改后经宽限窗口(默认 60 分钟)后 Job 自动收敛,重新提交能正常走通并真实扣减库存(走既有 `claimDoneForRededuct` 路径,`rollback_round` 递增真扣)。 +- **网关验证**:不适用,本单不涉及任何网关路由的接口。 +- **兼容性结论**:纯后台 Job 行为扩展,对前端零接口契约影响;前端不需要做任何代码改动,只需理解上述宽限窗口行为,避免把窗口期内的 808902 误报为新 bug。 + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#7390](https://git.1814.love:8443/wx/HL/issues/7390) +- 前置依赖:`#7339`(酒店库存操作幂等键,已合入,建立了原有 `PENDING` 补偿扫描)、`#7323`(`source_type` 列落地,本单据此做来源分支) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#7390](https://git.1814.love:8443/wx/HL/issues/7390) +- **PR**: 尚未创建(合并后回填 [#N](https://git.1814.love:8443/wx/HL/pulls/N)) +- **Merge commit**: 尚未产生(合并后回填 [https://git.1814.love:8443/wx/HL/commit/sha](https://git.1814.love:8443/wx/HL/commit/sha)) + +### 联系人 + +- **后端负责人**: @wx +- **前端负责人**: @mmg