Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
377 lines
27 KiB
Markdown
377 lines
27 KiB
Markdown
# 答卷差异对账 v1(TASK_3 · FC 统一发起,SH 执行修复并回传可追踪结果)
|
||
|
||
## 2026-10-22代码精修修订(优先于本文初版描述)
|
||
|
||
- 指令签名方是FC,目标消费方是SH,两种appKey分开核验。SH必须设置 `svchall.answer-reconcile.source-app-key` 为FC实际签名身份;空值拒收。信封sourceAppKey也与该配置核对;不能要求FC使用SH身份签名。
|
||
- 派发不可用同样先领取尝试次数,再进入有界退避/FAILED;不在QUEUED状态调用只匹配DISPATCHED的更新。
|
||
- RUNNING心跳可多次刷新,绑定当前attempt;同attempt的RUNNING记录可以推进为终态结果。COMPLETED必须同时声明两侧扫描完成且中心manifest已物化完整。终态结果重复提交只作身份核验与幂等确认,不重新执行;终态任务不再提供清单/快照/缺席证明,返回TASK_TERMINAL。
|
||
- SH同/旧attempt重复指令不重开终态行;更高attempt才可重新排队,work_version防止旧Worker回写新指令。执行/结果上报失败使用独立retry_count,默认6次、指数退避至上限;结果上报失败只重报,不重扫。新增本地字段升级脚本add_reconcile_work_recovery.sql未执行;新库使用answer_reconcile_work.sql,二者择一。
|
||
- 修复冲突后的中心重读穿透缓存,缓存按页清理;DEFERRED保留计数/错误/候选但不推进水位。权威答复及信封逐项核对task/consumer/school/form/answer/version/grant,不把合法签名等同任意坐标合法。
|
||
- 授权决策复用TASK_1的requireRead/requireSubscribe;资源校域校验已补齐,但应用可服务学校集合事实源仍未实现,不能宣称解决Task2 §9#1的全部问题。
|
||
- 本轮仅编译/跳过测试打包;历史36+36测试结果不构成精修后状态机、数据库并发或签名互调验证。Task4已交付独立文档草案,不实施候选schema功能。
|
||
|
||
日期:2026-10-21。适用范围:`form-center`(中心/发起方)与 `svc-hall`(消费方/执行方)之间的答卷集合对账协议。
|
||
前置:TASK_1 消费授权 v3(`docs/consumer-access-v3.md`)、TASK_2 快照投递 v2 及其前置修正
|
||
(`docs/answer-delivery-v2.md` §8/§8.1、`docs/tasks/TASK_2_PREFIX_CHECK.md`)。
|
||
本文是**协议与边界的事实源**;TASK_1/2 的文件一律只调用、不修改。
|
||
|
||
---
|
||
|
||
## 1. 这套东西解决什么、不解决什么
|
||
|
||
游标增量拉取(`GET /oapi/form/answers`)回答的是"最近变了哪些",它**不能**回答三个问题:
|
||
两侧集合到底一不一致、不一致时以谁为准、修复之后有没有可追踪的回报。本任务补的就是这三件事。
|
||
|
||
| 它是什么 | 它不是什么 |
|
||
|---|---|
|
||
| 一轮**双边集合验证** + 有界修复 + 可追踪回报 | 不是第二份增量拉取(一致的行不会去中心取值) |
|
||
| 中心签发的**任务级权威结论**(清单/当前快照/缺席证明) | 不是消费方本地判断的背书(本地永远不能自证"这行该删") |
|
||
| 投递链(TASK_2)之外的**独立修复通道** | 不是重投、不是建件、不是流程补偿 |
|
||
| `answer_reconcile_*` 三张中心表 + 一张消费表 | **不是** `answer_op_log`(不得拿它当任务/答卷/投递主表) |
|
||
|
||
明确不在范围内:`svc-hall` 的 `FlowReconcileJob` 仍是流程恢复域,本任务不碰它;
|
||
对账修复不生成虚假的 MQ 消费成功回执,不重放历史 intent,不自动发起办件或流程命令。
|
||
|
||
---
|
||
|
||
## 2. 任务坐标:一行任务 = 一个「消费方 appKey + 消费租户域 + 表单」
|
||
|
||
`consumerSchoolId` 与投递行同口径,**就是答卷的归属校**(中心侧 `findByUuidAndSchId(uuid, consumerSchoolId)`
|
||
与 `listEnabledSubscriptions(formUuid, answer.getSchId())` 都是这个语义)。
|
||
|
||
任务范围只到**一张表单**,不做"一行任务覆盖全部表单"的设计,理由不是审美:
|
||
|
||
1. 清单游标与消费端本地扫描游标都是 `answerUuid`(32 位小写十六进制,单字段升序)。
|
||
跨表单就必须复合游标,两侧无法锁步比较,多余行判定会失去依据;
|
||
2. **全量 = 按生效订阅裂变成多行任务**,每行仍然单表单。裂变性受 `max-tasks-per-run` 约束,
|
||
因此不存在"一次人工触发把整库变成一个无界大任务"的形态;
|
||
3. 单并发、取消、租约、结果幂等这些语义都落在"一行任务"上才有清晰定义。
|
||
|
||
`scope=ANSWER`(单答卷)是同一坐标下的**一行清单**特例:清单里就这一行(或零行=缺席候选)。
|
||
|
||
### 2.1 简单表单不强建消费者
|
||
|
||
建任务的第一道闸是 TASK_1 的生效订阅(`enabled=1 AND subscribe_allowed=1 AND read_allowed=1`)。
|
||
没有订阅就没有任务——本任务**不创建** grant 行,也不因为"这张表看起来有人要"而代填授权。
|
||
读权与订阅权必须同时开着:清单答复与权威快照都走 `requireRead`,而消费端修复前的新鲜核验走
|
||
`requireSubscribe`,缺一条就会造出一轮"能建、必失败"的任务。
|
||
|
||
`scope=ANSWER`(单答卷)**同样过这道闸**:答卷归属校就是它的消费租户域坐标,该域上没有生效订阅时
|
||
直接以 `GRANT_REVOKED` 拒建——否则"指定一份答卷"会变成撤权后的后门。
|
||
|
||
---
|
||
|
||
## 3. 不可变清单(manifest)
|
||
|
||
### 3.1 物化
|
||
|
||
`AnswerReconcileReadRepository`(本任务新增的只读仓库)按 `(form_uuid, sch_id)` 的 `uuid` 升序 keyset
|
||
分页读 `answer`,在**一个一致读事务内**把摘要写进 `answer_reconcile_manifest_row`:
|
||
|
||
```
|
||
(answer_uuid, revision, content_hash, is_deleted, form_uuid, form_version, seq_no)
|
||
```
|
||
|
||
- **含墓碑**:`is_deleted=1` 的行照样入清单。"缺席"与"存在但已删"是两个不同结论,
|
||
后者必须走权威修复保留本地业务扩展,不能被当成前者;
|
||
- `content_hash` 由 `AnswerSnapshotService#contentHash` 现算(`fc.canon.v1`),
|
||
与投递行同一条规范化实现。**本任务不复制任何规范化逻辑**;
|
||
- `form_version` 取该行 `answer.version`(答卷钉住的 schema 版本),不是表单当前版本;
|
||
- 清单行的 JPA 列全部 `updatable=false`,仓库层不提供 update/delete——它是这一轮的冻结事实源;
|
||
- 事务回滚时**清单行与检查点一起消失**,因此崩溃后是从头重物化,不存在"半截清单 + 半截检查点"
|
||
这种会把差异判没的形态;
|
||
- 修订号为 0 的存量行原样入清单,但**不可被权威修复**(消费端 `validate` 要求 revision ≥ 1),
|
||
Worker 按 `DIFF_REVISION_ZERO` 只报告。
|
||
|
||
### 3.2 分页与完整性
|
||
|
||
```
|
||
GET /oapi/form-sync/tasks/{taskId}/manifest?cursor={answerUuid}&size={n}
|
||
→ { taskId, rows[], nextCursor, completed, totalRows, totalPages, taskState }
|
||
```
|
||
|
||
- `size` 默认 `page-size`(200),上限 500;游标非 32 位十六进制即拒(页码与库内 id 都不是合法游标);
|
||
- **`completed=true` 是唯一的结束标记**。某一页空了不构成结束证据——中心清单物化完就有
|
||
`total_rows/total_pages`,读侧只需把 `completed` 当判据;
|
||
- 清单未完整(`manifestComplete=0`)时该端点直接拒答分页,避免"读到空=集合空"的误判;
|
||
- 消费端把每页的 `taskState` 也当判据:中心已把任务落成 `CANCELLED` 时立刻停止剩余修复。
|
||
|
||
### 3.3 资源上限(超限=拒绝,不是硬扛)
|
||
|
||
| 键 | 默认 | 越界处置 |
|
||
|---|---|---|
|
||
| `max-rows` | 20000 | 建任务前按 `count(answer)` 预检;越界→不落清单,任务 `FAILED` + `RESOURCE_LIMIT` + 原因 |
|
||
| `max-pages` | 200 | 双保险(页大小被调大时仍兜底);物化途中越界→整笔回滚 + `FAILED` |
|
||
| `max-tasks-per-run` | 20 | 一次调度/一次 ALL 人工触发最多裂变这么多行 |
|
||
| `max-error-items` / `max-error-chars` | 50 / 8000 | 结果明细截断存,计数不丢 |
|
||
| SH `max-pending-extras` | 500 | 多余行候选越界→**本轮一行都不退役**,只报告 |
|
||
|
||
拒绝建任务会**留下一行 `FAILED` 任务**并带稳定码与原因:拒绝必须是可查的事实,
|
||
而不是操作人屏幕上闪过的一行字。这一行永远不会被派发(`manifest_state=FAILED`)。
|
||
|
||
---
|
||
|
||
## 4. 任务状态机
|
||
|
||
```
|
||
物化完成 派发 2xx 结果回报
|
||
QUEUED ──────────────────────────► QUEUED ────────────► DISPATCHED ────────────┬─► COMPLETED
|
||
│ (manifest PENDING→COMPLETE) (lease 起算) ├─► PARTIAL
|
||
│ ├─► FAILED
|
||
└── 超限/撤权/跨校 → FAILED(带原因,永不派发) └─► CANCELLED
|
||
RUNNING ← 消费端进度回报(只续租,不落终态)
|
||
```
|
||
|
||
七个状态是本任务的验收闭集,语义钉死:
|
||
|
||
| 状态 | 判据 | 明确不代表 |
|
||
|---|---|---|
|
||
| `QUEUED` | 清单已完整(或尚未物化),等派发 | — |
|
||
| `DISPATCHED` | 对端 2xx 收下指令并落库 | **不代表**任何修复;不代表消费方本地数据已对 |
|
||
| `RUNNING` | 消费端进度回报刷新过租约 | 不是终态,不推进计数结论 |
|
||
| `COMPLETED` | 清单与本地扫描都完整,且无未决差异 | 不代表"修好了很多行"(可能是本来就一致) |
|
||
| `PARTIAL` | 跑完了,但仍有只报告项/暂停项/重试耗尽项 | 不等于失败,也不等于成功 |
|
||
| `FAILED` | 资源拒绝、物化中断、派发或租约预算耗尽 | 不猜对端状态 |
|
||
| `CANCELLED` | 人工取消,或核验时发现撤权/授权纪元进位 | — |
|
||
|
||
推进规则:**只有 result 回报能推终态**;`attempt` 是幂等键的一部分。
|
||
|
||
- 派发领取与租约续期都是**条件更新**(`claimForDispatch` 带 `state='QUEUED' AND manifest_complete=1
|
||
AND (next_dispatch_at IS NULL OR <= now)`),两个扫描线程同时看到同一行时只有一个能刷新租约;
|
||
- 同一消费方存在 `DISPATCHED/RUNNING` 在途任务时**不派下一条**(单并发);
|
||
- 租约超期:`attempt < max-dispatch-attempts` → 回 `QUEUED` 并按 `base×2^n`(封顶 `backoff-cap-seconds`)
|
||
重派,同 `taskId` 复用检查点;到上限 → `FAILED`,留可查记录,不无限重投。
|
||
- 消费端收到**更大 attempt** 的指令时:本地行若已是终态或已停摆,就按新 attempt **重新排队并从水位续读**
|
||
(只回 `duplicate` 吸掉会让中心一路重派到预算耗尽,最后落成 `FAILED` 假象);
|
||
若本地仍在跑且租约有效,则只刷新身份与坐标,**不打断正在执行的这一轮**。
|
||
更旧 attempt 一律不动 `attempt`、不动检查点、不动已提交结论。
|
||
|
||
---
|
||
|
||
## 5. 协议端点与身份纪律
|
||
|
||
| 方向 | 端点 | 用途 |
|
||
|---|---|---|
|
||
| FC | `GET /oapi/form-sync/tasks/{taskId}/manifest` | 读这一轮的不可变清单分页 |
|
||
| FC | `GET /oapi/form-sync/tasks/{taskId}/answers/{answerUuid}[?expectedRevision=]` | 权威当前快照信封,或权威缺席证明 |
|
||
| FC | `POST /oapi/form-sync/tasks/{taskId}/result` | 结果回报(`(taskId, attempt)` 幂等) |
|
||
| FC | `POST` 派发 → SH `POST /oapi/form-sync/reconcile` | 指令下发(服务端登记地址 + 平台签名) |
|
||
| FC 管理面 | `/api/form/admin/sync/**` | 建任务/列表/详情/结果/取消/单答卷重放/投递失败查看 |
|
||
|
||
五条不可绕的纪律:
|
||
|
||
1. **身份只来自平台验签**。中心读 `request.getAttribute(AppAuthFilter.APP_AUTH_ATTR)` 经
|
||
`ConsumerPrincipalResolver` 换 `ConsumerPrincipal`;请求体里自报的 `consumerAppKey` 只用于
|
||
**比对是否冒充**,与验签身份不符即 403。消费端同理:指令的 `consumerAppKey` 与验签来的 appKey
|
||
必须都等于本应用消费身份,否则整条指令拒收。
|
||
2. **每一次**清单/快照/结果调用都重新核验:任务存在 → 身份等于任务目标 → 未取消 →
|
||
最新 grant 仍生效(`enabled`/`subscribe`/`read` 三条)且**纪元未进位**。
|
||
撤权或纪元进位时中心把任务落成 `CANCELLED` 并返回稳定码,消费端据此停止剩余投递与修复——
|
||
绝不因"建任务时有权"永久放行。
|
||
3. **派发地址只能来自服务端登记表** `fc.answer.reconcile.targets.{consumerAppKey}`。
|
||
指令载荷里没有 callbackUrl 字段,浏览器传入的任何 URL 无从采信;未登记 →
|
||
`TARGET_NOT_REGISTERED`,任务退回 `QUEUED` 等人工处置,不改数据。
|
||
4. 缺席证明与权威快照都由中心构造,`formUuid` 取任务行、`formVersion` 取中心**当前答卷行**
|
||
(不从旧清单猜版本),`eventId` 是服务端 UUID(≤36),`proofId` ≤128。
|
||
`proofId` 只是可追踪引用,**不是身份凭证**;消费端严禁把 `repairAuthoritative` 直接绑到浏览器可提交的入口上。
|
||
5. 敏感数据:答卷 `values` 只出现在签名任务端点按授权下发的快照里;错误明细与日志里
|
||
只有 `consumer / task / range / answer / revision`,逐字段搬运并截断,绝不整包序列化请求体。
|
||
|
||
---
|
||
|
||
## 6. 差异分类与修复规则(消费侧)
|
||
|
||
Worker 每轮从**同一水位**读两侧一页(中心清单 + `AnswerProjectionStore#manifestPage`),
|
||
取两侧页末键的较小者作本轮边界,比较 `(answerUuid, revision, contentHash, isDeleted)`:
|
||
|
||
| 方向 | 稳定码 | 处置 |
|
||
|---|---|---|
|
||
| 中心有、本地无 | `MISSING` | 取中心**当前**快照 → `repairAuthoritative(env, null, null, null, proofId)`(期望三元组全 null=观察到缺失) |
|
||
| 本地 revision 更低 | `STALE` | 取中心当前快照 → 按本地扫描三元组条件更新 |
|
||
| 同 revision 异 hash | `HASH_CONFLICT` | 先记冲突,再取中心当前值走**同一权威入口**;不走普通 `applySnapshot` 强盖 |
|
||
| `isDeleted` 不符 | `TOMBSTONE` | 同一权威修复原语;本地业务扩展列(`biz_ext_json`/`apply_id`/`flow_guid`/`link_state`)由条件更新排除在写集之外 |
|
||
| 本地 revision 更高 | `LOCAL_AHEAD` | 向中心复核**当前值**:中心更大→按 `STALE` 修到新值;否则只报告,**禁止降级覆盖** |
|
||
| 本地有、清单里没有 | `EXTRA_ROW` | 只登记候选,见 §7 |
|
||
| 修订号为 0 | `DIFF_REVISION_ZERO` | 只报告(契约要求 revision ≥ 1 才受理修复) |
|
||
|
||
**一致的行不向中心取快照**:完整扫描不是第二份全量拉取,只有检出差异才发起一次权威读取。
|
||
|
||
### 6.1 RepairResult 四分支
|
||
|
||
| 返回值 | 含义 | Worker 处置 |
|
||
|---|---|---|
|
||
| `APPLIED` | 数据面已按中心当前值落定 | 计数 +1;**不代表**建件或流程成功 |
|
||
| `LOCAL_CHANGED` | 本地期望三元组已变化(含条件更新未命中) | 重读本地行与中心当前值后**有界重试** |
|
||
| `LOCAL_AHEAD` | 本地 revision 更高 | 停止该条,向中心复核,不降级 |
|
||
| `DEFERRED` | 新鲜订阅核验不可达/撤权/授权纪元不等 | 暂停修复,本轮按任务授权状态收尾(`PARTIAL`) |
|
||
|
||
### 6.2 并发与回滚(本轮最关键的一条)
|
||
|
||
- `projection_version` 乐观锁冲突、`uk_projection_answer` 并发插入的唯一键异常,都必须使
|
||
**当笔事务整笔回滚**,然后在**事务外**重读后再有界重试(`max-repair-retries`,默认 3)。
|
||
Worker 的执行循环本身**不开事务**:领取、检查点落库、单次修复各自是独立短事务,
|
||
这样才可能在"已标 rollback-only 的事务"之外续跑。
|
||
- 缺失插入撞唯一键后重读若已存在,则本轮该条从 `MISSING` 退化为按本地三元组比较,不硬插第二条。
|
||
- **运行联调前必须确认 `svc-hall/server/sql/add_fc_projection_version.sql` 已应用**(已有库),
|
||
或新库直接用含该列的建表脚本。TASK_3 不得绕开这一列。
|
||
|
||
### 6.3 禁止事项
|
||
|
||
对账修复**禁止调用 `applySnapshot`**。它仍是普通 MQ 消费入口,保持"同版异 hash 不覆盖"的既有规则,
|
||
并可能关联意图/建件/发流程。修复入口只更新中心快照列与修复留证(`last_event_id`/`conflict_detail`),
|
||
不碰 `inbox`/`intent`/`apply`/`flow`,不生成 MQ 消费成功回执。
|
||
|
||
---
|
||
|
||
## 7. 多余行与权威缺席证明
|
||
|
||
`retireAbsent(AuthoritativeAbsenceProof)` 的前置条件**全部**成立才允许调用:
|
||
|
||
1. 中心清单整轮完整结束(`manifestComplete=1` 且本轮读到 `completed=true`);
|
||
2. 本地图像也扫完(`localCompleted=1`);
|
||
3. 该行属于本任务的 `(sch_id, form_uuid)`;
|
||
4. 该行 `createTime` **不晚于**任务起点 `windowStart` 且**非空**;
|
||
晚于起点=期间新增,为空或归属不确定=不可判,一律只报告;
|
||
5. 中心对同一 `answerUuid` 答复 `presence=ABSENT` 且证明的
|
||
`taskId / formUuid / schId / answerUuid / expectedRevision` 与本地候选逐字一致;
|
||
6. 多余行候选总数未越 `max-pending-extras`。
|
||
|
||
`expectedRevision` 由消费端扫描观察值提供,作用是与 `retireAbsent` 内部的"版本不符即不退役 + 标冲突"
|
||
形成并发护栏;**它不是中心对本地版本的断言**。
|
||
|
||
任何跨页失败、撤权、范围改变、候选超限的情况:未见到的行绝不当作"应删除",
|
||
已经登记为候选的行也**整体不退役**(宁缺勿滥),只把差异报回去。
|
||
|
||
---
|
||
|
||
## 8. 与 TASK_2 服务能力的接触面(只调用)
|
||
|
||
| 能力 | 中心侧 | 消费侧 |
|
||
|---|---|---|
|
||
| 权威当前快照 | `AnswerSnapshotService#currentAuthorized(caller, consumerSchoolId, answerUuid)` | — |
|
||
| 修复入口 | 签发信封 | `AnswerProjectionStore#repairAuthoritative(env, expRev, expHash, expDel, proofId)` |
|
||
| 本地集合扫描 | — | `AnswerProjectionStore#manifestPage(schId, formUuid, cursor, size)` |
|
||
| 缺席处置 | 签发证明 | `AnswerProjectionStore#retireAbsent(proof)` |
|
||
| 投递重放 | `AnswerDeliveryService#replayAuthorized(...)`(管理面单答卷重放) | — |
|
||
| 投递失败查看 | `AnswerDeliveryService#failedPage(...)` | — |
|
||
| 授权判据 | `FormConsumerAccessService#requireRead/requireSubscribe/listEnabledSubscriptions`、`FormConsumerGrantRepository` | 修复前 `verifyGrants` 新鲜核验(TASK_2 内部) |
|
||
|
||
任务书 §59 的停止线本次未触发:**没有**在 `OapiFormService` / `AnswerRepository` /
|
||
`FcEventPublisher` / `AnswerProjectionStore` 里补快捷查询。摘要取数走本任务新增的
|
||
`AnswerReconcileReadRepository`;哈希复用中心既有实现,未复制规范化代码。
|
||
|
||
---
|
||
|
||
## 9. 配置键(默认值即交付形态,本任务不改两个应用的 `application.yml`)
|
||
|
||
中心 `fc.answer.reconcile.*`(`AnswerReconcileConfiguration.AnswerReconcileProperties`):
|
||
|
||
| 键 | 默认 | 说明 |
|
||
|---|---|---|
|
||
| `schedule-enabled` | **false** | 每日建任务总闸;关时一行任务都不建 |
|
||
| `schedule-cron` | `0 0 2 * * ?` | 拟配置时点 |
|
||
| `schedule-zone` | `Asia/Shanghai` | 营业日按此时区取 |
|
||
| `dispatch-enabled` | **false** | 物化 + 派发 + 租约回收总闸;关时仍可建任务、查结果、手动重放 |
|
||
| `page-size` / `max-rows` / `max-pages` | 200 / 20000 / 200 | 见 §3.3 |
|
||
| `max-tasks-per-run` | 20 | 裂变性上限 |
|
||
| `lease-seconds` | 900 | 租约 |
|
||
| `max-dispatch-attempts` | 4 | 含首派的派发预算 |
|
||
| `backoff-base-seconds` / `backoff-cap-seconds` | 60 / 3600 | 退避 |
|
||
| `targets.{appKey}` | 空 | **必填**才能派发:已登记的消费方后台服务地址(含 context-path) |
|
||
| `dispatch-path` | `/oapi/form-sync/reconcile` | 派发路径 |
|
||
| `source-app-key` | 空→回落 `yh.app-auth.app-key` | 信封 `sourceAppKey` |
|
||
| `scan-interval-ms` / `scan-initial-delay-ms` | 15000 / 45000 | 中心扫描闹钟 |
|
||
|
||
消费侧 `svchall.answer-reconcile.*`:
|
||
|
||
| 键 | 默认 | 说明 |
|
||
|---|---|---|
|
||
| `enabled` | **false** | 执行总闸(接收落库不受它管——落库只是事实,执行才动数据) |
|
||
| `lease-seconds` | 900 | 本地执行租约(超租约的 `RUNNING` 可被重新领取并从水位续读) |
|
||
| `max-repair-retries` | 3 | 单条差异的有界重试预算 |
|
||
| `max-pending-extras` | 500 | 多余行候选上限(越界=整轮不退役) |
|
||
| `max-error-items` | 50 | 明细上限 |
|
||
| `max-work-per-scan` | 1 | 每 tick 执行任务数(单并发) |
|
||
| `progress-report-rounds` | 10 | 每 N 轮向中心回报一次 `RUNNING` 以续租 |
|
||
| `backoff-base-seconds` / `backoff-cap-seconds` | 60 / 1800 | 中断/回报不可达的退避 |
|
||
| `scan-interval-ms` / `scan-initial-delay-ms` | 30000 / 20000 | 消费侧扫描闹钟 |
|
||
|
||
### 9.1 部署顺序(硬约束)
|
||
|
||
1. 中心:`sql/answer_reconcile_v1.sql`(三张表;已有库先确认表不存在,脚本非幂等);
|
||
2. 消费侧:确认 `fc_answer_projection.projection_version` 已存在
|
||
(新库用 `fc_projection_and_apply_intent.sql`,已有库用 `add_fc_projection_version.sql`,二选一),
|
||
再执行 `sql/answer_reconcile_work.sql`;
|
||
3. 密钥与白名单:消费方必须已登记进中心 `yh.app-auth.trusted-apps`(本机为 `100018`),
|
||
中心 `100021` 必须已登记进消费方 `trusted-apps` —— 否则签名互验 401;
|
||
4. 服务目录/派发地址:中心侧配 `fc.answer.reconcile.targets.100018`,
|
||
消费侧配 `YH_OPEN_SERVICES_FORMCENTER`(`yh.open.services.form-center` 不在本轮可改文件内,需运行时注入);
|
||
5. 先有生效 grant,再开 `schedule-enabled` / `dispatch-enabled`。
|
||
**本轮没有开启任何调度**,也没有对真实数据做过派发或修复。
|
||
|
||
---
|
||
|
||
## 10. 验收证据与未验收项(诚实边界)
|
||
|
||
已取证的(本机桩级,规则单测):
|
||
|
||
- `FcReconcileTaskTest`:36 项 —— 调度唯一键幂等、未授权订阅不建任务、资源超限拒绝并留原因、
|
||
单并发不叠任务、跨校范围拒绝、裂变上限、清单物化含墓碑且哈希同源自 TASK_2、
|
||
物化越界整笔不落、UUID 游标跨页/整页到底/非法游标、清单未完整拒答分页、
|
||
信封坐标取中心已核验值(版本来自当前答卷行而非旧清单)、墓碑形态、
|
||
缺席证明三前置、期间新增不签证明、撤权/纪元进位即取消、跨消费方读取 403、
|
||
已取消任务不再答复、重复回报幂等吸收、旧 attempt 不倒退新 attempt、
|
||
假 result(超派发 attempt / 身份不符)拒、`COMPLETED` 与 `PARTIAL` 与 `RUNNING` 三种推进区别、
|
||
回报状态闭集外即拒、明细逐字段规整且有上限、派发 2xx 只标 `DISPATCHED`、
|
||
同消费方在途不派下一条、未登记地址不猜 URL、派发预算耗尽即 `FAILED`、
|
||
租约超期重派与耗尽落败、重放先重过 ACL 再执行、跨域寻址按不存在、取消幂等。
|
||
- `ShReconcileTaskTest`:36 项 —— 接收与执行解耦(2xx 只落 `QUEUED`)、三类身份不符拒收、
|
||
坐标/起点/attempt 校验、重复指令复用 taskId 与检查点、开关未开或出站未就绪不动数据、
|
||
缺失/陈旧/同版错 hash/墓碑四类走权威入口且 `applySnapshot` 永不被调、
|
||
本地更高不降级(含中心已推进到新值时修到新值)、一致行完全不向中心取快照、
|
||
修订号 0 只报告、`DEFERRED` 即暂停、`LOCAL_AHEAD` 分支、`LOCAL_CHANGED` 事务外重读后有界重试、
|
||
乐观锁与唯一键竞争整笔回滚后重试且预算耗尽只报告、多余行需完整扫描 + 匹配证明才退役、
|
||
证明坐标不符/只报告口径不退役、期间新增与 `createTime` 为空永不退役、候选越界整轮不退役、
|
||
UUID 游标锁步跨页、空页未到底即停滞且不回报假结果、跨页失败保检查点且不退役、
|
||
断线重启从水位续读并计 `resumed`、中心取消即停、中心不可达不写终态、
|
||
结果送不到时留 `QUEUED` 等重报、旧 attempt 回报被中心判陈旧时不覆盖结论、进度续租不落终态、
|
||
错误明细带全四坐标且不含 values、租约有效时新 attempt 只刷新身份不打断在跑的一轮。
|
||
|
||
门禁命令与退出码原文、首跑 7 红的根因表、以及门禁全绿后自查补的三处缺陷
|
||
(`ANSWER` 范围漏过订阅核验、一致行也向中心取快照、终态行被重派时不重新排队)见
|
||
`docs/tasks/evidence/task3/gate-commands.md`;Surefire 原文同目录。
|
||
|
||
**未取得、留给联调的证据**(不得被上述绿灯冒充):
|
||
|
||
1. 真实 MySQL:一致读快照的实际隔离效果、`uk_manifest_row`/`uk_result_attempt`/`uk_reconcile_work_task`
|
||
的真冲突、`projection_version` 乐观锁与 `uk_projection_answer` 并发插入的整笔回滚、
|
||
条件更新在并发下的实际原子性;两个 DDL 脚本本轮**未执行**;
|
||
2. 真实调度:本轮没有触发过 `@Scheduled`,02:00 时点与租约超期都是配置形态 + 桩级验证;
|
||
3. 真实签名互调:中心↔消费方的 `/oapi/**` 往返、密钥互登、服务目录解析都未在本机跑通
|
||
(本机 `yh.open.services.form-center` 需运行时注入);
|
||
4. 前端视觉证据:`form-center/front` 只到构建级验证;`/admin/sync` 的**菜单模块行尚未在 UserBase 登记**
|
||
(本任务禁改 `UserBase/**`),当前只能以 hash 地址直达;
|
||
5. TASK_2 §9 的五条待修(尤其跨校授权缺"调用方属于该租户域"的事实源)与 TASK_1 的四条审计问题
|
||
各自保留。本任务的应对是**拒绝建跨校任务**(`SCOPE_MISMATCH`),不是绕过它。
|
||
|
||
---
|
||
|
||
## 11. 修订记录
|
||
|
||
| 日期 | 变更 | 依据 |
|
||
|---|---|---|
|
||
| 2026-10-21 | v1 落地:任务坐标到表单为止(全量=按订阅裂变)、清单不可变物化 + `completed` 唯一结束判据、双流锁步水位、缺席证明六前置、`RepairResult` 四分支与整笔回滚后事务外重读、派发 2xx 与终态严格分离、调度与派发双闸默认关 | 任务书 TASK_3 改动点 1–8 与 `TASK_2_PREFIX_CHECK.md` 接入清单 |
|
||
| 2026-10-21 | 明确不接触 `application.yml`:全部键以 `@ConfigurationProperties`/`@Value` 默认值交付,env 注入生效 | 约束「配置在本任务独立 `@Configuration` 中,不争用 TASK_2 配置」 |
|
||
|
||
|
||
## 2026-10-23 R3/R4 收口
|
||
|
||
`ResultAck.decision` 采用 CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL。
|
||
`reportAttempt` 回声本次回报,既有 `attempt` 为中心派发轮次;`resultState` 表示被采纳或首次保存的结果。
|
||
旧轮不推进新轮;只有显式 CENTER_TERMINAL 可以在 stale=true 时令 SH 镜像中心终态并停止重试。
|
||
缺少有效确认、身份/轮次不匹配、网络/不可路由保持重报,不得记成功。
|
||
完整状态矩阵以 [answer-delivery-v2 §11](answer-delivery-v2.md#11-r3r4-对账结果回执裁决2026-10-23-收口) 为准。
|
||
MQ publisher confirm/return 与 B-09 仍是独立投递状态机,本批没有修改。
|
||
有效租约中的 SH RUNNING 行保留执行 attempt 与 work_version;过期或终态再接纳重派。
|
||
行锁领取、领取时二次到期判定、work_version 过期执行者保护共同防止旧执行清除新租约。
|