docs(changelog): 团期物料门复判与兜底重扫(#7526 / PR #7559)
changelog-filename-gate / validate (push) Successful in 2s
changelog-filename-gate / validate (push) Successful in 2s
新增 2 个端点:
- POST /v3/admin/order/group-batch/{groupBatchId}/recheck-material-gate(运维手工复判,未推进时回执指明挡门原因)
- POST /v3/internal/jobs/group-batch-material-gate/run(服务间,Quartz sys_job 1044 经 Feign 触发,公网网关不放行)
backend deployed + gateway verified,TEST 2026-09-12 实测:
事件路径 03:37:09 管理端动作 → 03:37:10 团期推进;Job 路径 cron 03:27:00 自行触发,sys_job_log 1044 SUCCESS。
这个提交包含在:
@@ -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<GroupBatchMaterialGateRespVO>`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
运维发现某个团期四项资源都配齐、合同保险也都出齐了,状态却还停在「资源准备中」时,手工敲一次门。推进成功即解卡;没推进则回执直接告诉你是哪道门挡着、哪一户挡着,不必再去翻库。
|
||||
|
||||
#### 入参字段表
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| 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<Integer>`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
服务间接口。由 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
|
||||
- 前端:待认领(团期详情页在合同保险面板动作后需重新拉取团期状态)
|
||||
在新工单中引用
屏蔽一个用户