diff --git a/changelogs-v2/2026-09/12_7526_团期物料门复判与兜底重扫-新增接口-管理后台.md b/changelogs-v2/2026-09/12_7526_团期物料门复判与兜底重扫-新增接口-管理后台.md new file mode 100644 index 00000000..4a1c2eab --- /dev/null +++ b/changelogs-v2/2026-09/12_7526_团期物料门复判与兜底重扫-新增接口-管理后台.md @@ -0,0 +1,311 @@ +--- +schema: "hl-changelog/v2" +ticket: "7526" +title: "团期物料门复判——手工复判端点 + 出具态事件触发 + 兜底重扫 Job" +consumer: "admin" +author: "jw(GIT)" +change_type: "新增接口" +backend_status: "deployed" +gateway_status: "verified" +frontend_status: "pending" +frontend_owner: "" +frontend_ref: "" +target_release: "" +verified_at: "2026-09-12" +status_note: "" +updated_at: "2026-09-12" +base: "dev-v3" +--- + +# order-v3/user: 团期物料门复判与兜底重扫 + +**服务**: hl-order-service-v3 / hl-user-service +**PR**: #7559 +**Issue**: #7526 + +--- + +## ⚠️ 关键变化 + +🔴 **本单修的是一个「团期永久卡死」的死锁,不是新功能**:团期从「资源准备中」推进到「物料准备中」有三道门,其中第三道要求全团活跃子订单合同已签、保险已承保;而合同与保险**恰恰是四项资源(房/车/导/摄)配齐之后才被允许出具的**。门变真的那一刻——客户签完合同、保单出单成功——原先**没有任何代码去复判**,团期于是永久停在「资源准备中」。本单补上两条触发源,**门条件一行未动**。 + +🔴 **推进现在可能在你没有发起任何团期操作时发生**:出具态变更(合同签署完成 / 保险承保完成 / 出具态被清空)会在事务提交后自动复判一次;另有兜底 Job 每 5 分钟重扫。前端若缓存了团期状态,**需要在合同保险面板动作之后重新拉取团期状态**,不能假定「我没点推进它就不会变」。 + +🔴 **新端点是给运维定点解卡用的,不是给业务流程用的**:`recheck-material-gate` 只在团期处于「资源准备中」时可用,其余状态一律返回 `589561`。它不改任何门条件,只是手工敲一次门,并在没推进时告诉你是**哪道门**挡着。 + +--- + +## 一、背景 + +团期状态机中「资源准备中 → 物料准备中」这一跳的判定(`GroupBatchService#maybeAdvanceToMaterialPreparing`)是 private 方法,全仓只有 5 个调用点,语义分别是「房/车/导/摄某一项 ready 翻真」与「成团」。 + +这 5 个时刻**全部发生在合同签署之前**——判据 `GroupBatchContractResolver#issuable` 要求「状态为资源准备中且四项 ready 全真」才放行出具,也就是说合同/保险只可能在四项配齐之后才出具、才签署。于是:四项翻真时第三道门必然判假;等第三道门真正变真时,已经无人敲门。 + +线上表现:团期四项资源都配齐了、每户合同也签了保险也出了,团期状态却一直停在「资源准备中」,物料准备无法开始,且没有任何报错。 + +本单补两条触发源:**事件(实时主链路)** + **兜底 Job(最终一致保险丝)**,并新增一个运维手工复判端点。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 手工复判物料门 | POST | `/v3/admin/order/group-batch/{groupBatchId}/recheck-material-gate` | 新增 | 运维定点解卡;未推进时回执指明挡门原因 | +| 2 | 物料门兜底重扫 | POST | `/v3/internal/jobs/group-batch-material-gate/run` | 新增 | 服务间接口,由 Quartz 经 Feign 触发;**公网网关不放行**,前端无需对接 | + +--- + +## 三、接口详情 + +### 1. 手工复判物料门 `POST /v3/admin/order/group-batch/{groupBatchId}/recheck-material-gate` + +**VO**: `Long → Result` + +#### 使用场景 + +运维发现某个团期四项资源都配齐、合同保险也都出齐了,状态却还停在「资源准备中」时,手工敲一次门。推进成功即解卡;没推进则回执直接告诉你是哪道门挡着、哪一户挡着,不必再去翻库。 + +#### 入参字段表 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| groupBatchId | Path | Long | ✅ | 团期主订单 ID | 团期ID | + +#### 出参字段表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| advanced | Boolean | 本次调用是否把团期推进到了「物料准备中」 | +| currentStatus | String | 复判后的团期状态 code | +| currentStatusName | String | 复判后的团期状态中文名 | +| blockedGate | String | 挡门标识,`RESOURCE_NOT_READY`=四项资源未配齐 / `CONTRACT_INSURANCE_NOT_READY`=合同或保险未出齐;已推进或疑似并发时为 null | +| blockedOrderId | String | 挡门的子订单 ID(字符串序列化防精度丢失);非逐户挡门时为 null | +| blockedReason | String | 人可读的挡门原因;已推进时为 null | + +#### 请求示例 + +```json +POST /v3/admin/order/group-batch/2097500233511362561/recheck-material-gate +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "advanced": true, + "currentStatus": "MATERIAL_PREPARING", + "currentStatusName": "物料准备中", + "blockedGate": null, + "blockedOrderId": null, + "blockedReason": null + }, + "traceId": null, + "success": true +} +``` + +#### 空数据 / 降级响应 + +门未开时 HTTP 200 + `code=200`,`advanced=false` 并带挡门诊断(这是正常回执,不是错误): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "advanced": false, + "currentStatus": "RESOURCE_PREPARING", + "currentStatusName": "资源准备中", + "blockedGate": "CONTRACT_INSURANCE_NOT_READY", + "blockedOrderId": "2097500234186702849", + "blockedReason": "子订单 2097500234186702849 合同状态 GENERATED(需 SIGNED)" + }, + "traceId": null, + "success": true +} +``` + +三道门重读都满足却没推进(被并发改写)时,`blockedGate` **留 null**、不编造原因: + +```json +{ + "code": 200, + "message": "成功", + "data": { + "advanced": false, + "currentStatus": "RESOURCE_PREPARING", + "currentStatusName": "资源准备中", + "blockedGate": null, + "blockedOrderId": null, + "blockedReason": "三道门复判均满足但状态未变,可能被并发操作改写,请重试一次" + }, + "traceId": null, + "success": true +} +``` + +#### 错误响应 + +```json +{ + "code": 589561, + "message": "团期当前状态为「招募中」,不在「资源准备中」,无需复判物料门", + "data": null, + "traceId": null, + "success": false +} +``` + +```json +{ + "code": 589500, + "message": "团期不存在", + "data": null, + "traceId": null, + "success": false +} +``` + +#### 业务边界 + +- 需要 `group-batch:manage` 权限 +- 仅「资源准备中」可用;其余状态(含已推进到「物料准备中」及之后)一律 `589561`,这是设计行为而非失败 +- 幂等:重复调用与调用一次等价,时间线只由 CAS 赢家写一条 +- 本端点**不改任何门条件**,推进判据仍在 `GroupBatchService#maybeAdvanceToMaterialPreparing`;回执里的诊断是事后重读一次得出的解释 + +--- + +### 2. 物料门兜底重扫 `POST /v3/internal/jobs/group-batch-material-gate/run` + +**VO**: `void → Result` + +#### 使用场景 + +服务间接口。由 hl-user-service 的 Quartz(`sys_job.job_id = 1044`,cron `0 2/5 * * * ?`)经 Feign 触发,游标分批扫「资源准备中 + 四项 ready 全真」的团期逐条复判。**公网网关不放行 `/v3/internal/**`(返 403),前端无需也无法对接**,此处列出仅供核对契约边界。 + +#### 入参字段表 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| X-Internal-Token | Header | String | ✅ | 内部服务令牌 | 由 `InternalAuthInterceptor` 校验 | + +#### 出参字段表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| data | Integer | 本轮成功推进到「物料准备中」的团期数;未抢到分布式锁返回 0 | + +#### 请求示例 + +```json +POST /v3/internal/jobs/group-batch-material-gate/run +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": 1, + "traceId": null, + "success": true +} +``` + +#### 空数据 / 降级响应 + +无可推进团期时返回 `data=0`;未抢到分布式锁(另一实例正在跑)同样返回 `data=0`,这不是失败。 + +#### 错误响应 + +```json +{ + "code": 403, + "message": "接口不可访问", + "data": null, + "traceId": null, + "success": false +} +``` + +#### 业务边界 + +- 分布式锁 key 为 `group-batch-material-gate`,TTL 5 分钟,与自动成团 / 生命周期推进 / enrolled 对账各自独立 +- 单条团期复判失败只记日志、不中断本轮 +- 每批上限 300 条,按 `group_batch_id` 升序游标推进 +- 与事件路径调同一个幂等入口,重复触发无副作用 + +--- + +## 四、契约约束与正确调用方式 + +- **`blockedOrderId` 是字符串**:Long 精度在 JS 侧会丢,出参已 `@JsonSerialize(ToStringSerializer)`,前端按字符串处理,不要 `parseInt`。 +- **`advanced=false` 不是错误**:`code` 仍为 200、`success` 仍为 true。判「有没有推进」看 `data.advanced`,判「调用有没有成功」看 `code`。 +- **`blockedGate` 为 null 且 `advanced=false`** 表示疑似并发,提示用户重试即可,不要据此展示「原因未知」的错误页。 +- **不要用本端点做业务流程推进**:正常链路由出具态事件自动完成,本端点是运维兜底。 + +--- + +## 五、数据库行为 + +| 表 | 行为 | +|---|---| +| `order_group_batch` | 门全过时 CAS 更新 `batch_status`:`RESOURCE_PREPARING → MATERIAL_PREPARING`;CAS 落空不重试、不报错 | +| `group_batch_status_log` | 推进成功时新增一条 `event_type=BATCH_RESOURCE_READY`、`change_type=STATUS`、`content=四项资源全就绪 + 合同保险出齐`。事件路径触发的 `operator_type=ADMIN`,Job 路径触发的 `operator_type=SYSTEM`,可据此区分来源。写入失败只 WARN 降级,不影响推进 | +| `sys_job`(hl_user_service) | 新增 `job_id=1044` 团期物料门兜底重扫,`invoke_target=groupBatchMaterialGateJob.run()`,cron `0 2/5 * * * ?`,`misfire_policy=FIRE_ONCE`,`concurrent=FALSE`,**`status=PAUSED`**(迁移 `V20260912_110`) | + +**无新增表、无新增字段、无 DDL**。合同/保险是否出齐仍按 `order_main.contract_status` / `insurance_status` 自评估,不落团期级冗余列。 + +--- + +## 六、边界行为 + +- **已取消子订单不参与合同/保险门判定**:`order_status=CANCELLED` 的子订单跳过,只被它挡住时视为门已过。 +- **团期无活跃子订单**:合同/保险门放行。 +- **`needs_guide` / `needs_photographer` 为 false 的团期**:对应 ready 位已在成团时免闸置真,天然满足四项条件。 +- **合同/保险状态为 null(未出具)**:挡门回执文案渲染为「未出具」,不是字面 `null`。 +- **软删团期**:一律按「团期不存在」`589500` 处理。 +- **事件监听器失败不重抛**:出具态镜像已提交、无从回滚,监听器异常被吞掉并记 ERROR 日志,由兜底 Job 补上——这正是必须同时做「事件 + Job」两条路的原因。 + +--- + +## 七、不影响范围 + +- **门条件一行未动**:四项 ready 与合同/保险的判据完全不变,不放宽也不收紧。 +- **原有 5 个推进调用点未触碰**(某项 ready 翻真 ×4 + 成团 ×1),行为与改前逐字相同。 +- **合同/保险出具链路不变**:`GroupBatchContractResolver#issuable` 零改动;两个镜像服务只在回写之后**追加**一次事件发布,发布失败只 WARN,不影响镜像本身。 +- **不影响「物料准备中 → 待出行」及之后的任何一跳。** +- `GroupBatchIssueGateContract` 及其实现类**仅订正过期 javadoc**,无运行时行为变更。 + +--- + +## 八、测试环境已验证 + +`api.test.1814.love:9443`,2026-09-12,分支 `feature/7526-material-gate-reevaluate @ 32b9ea9a3`(order-v3 + user-service 同批),验收后已合入 `dev-v3` 并把 TEST 换回主干(`09e8cbfaa`)。 + +- **先复现 bug 本体**:团期四项 ready 全真、全团合同 SIGNED 保险 INSURED,静置 30 秒状态纹丝不动。 +- **事件路径**:`03:37:00` Quartz 刚跑过未推进 → `03:37:09` 管理端「作废重开保险」→ **`03:37:10` 团期 `RESOURCE_PREPARING → MATERIAL_PREPARING`**,新增 `BATCH_RESOURCE_READY`(`operator_type=ADMIN`)。 +- **Job 路径**:直接 SQL 改出具态绕过事件 → 团期确认仍卡住 → cron 于 `03:27:00` 自行触发,`sys_job_log` 1044 `SUCCESS` → 团期推进,`BATCH_RESOURCE_READY`(`operator_type=SYSTEM`)。Quartz → 白名单 → 桥接 Bean → Feign 整链一次跑通。 +- **幂等**:已推进团期上再跑一次 cron + 手工复判端点各一次 → 状态不变、时间线不新增。 +- **挡门回执**:`blockedGate=CONTRACT_INSURANCE_NOT_READY`、`blockedOrderId` 指向真实未签子订单;保险缺失场景文案为「保险状态 未出具(需 INSURED)」。 +- **错误码**:对「招募中」团期调同端点 → HTTP 200 + `589561`。 +- **单测**:新增 35 例 / 7 个类;`hl-user-service` 全量 3985 例 BUILD SUCCESS;order-v3 分两遍共 10169 例,ArchUnit 6 个类全绿。 + +--- + +## 十、相关文档 + +- Issue #7526、PR #7559 +- 前置:#7023(合同/保险门)、#7249(出具判据改判四项 ready)、#7105 / #7243(团期合同保险面板 GB-ADM-030/031) + +--- + +## 关联 / 联系人 + +- 后端:jw +- 前端:待认领(团期详情页在合同保险面板动作后需重新拉取团期状态)