# 答卷差异对账 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 过期执行者保护共同防止旧执行清除新租约。