Files
form-center-service/docs/answer-delivery-v2.md
T
chenxing 6ff39b0cec Define authoritative reconciliation acknowledgements
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.
2026-10-23 10:51:16 +08:00

29 KiB
Raw Blame History

答卷投递 v2 与可信建件(answer-delivery-v2)

任务书:docs/tasks/TASK_2.md(【任务 2】可信 FC 提交 + 完整答卷投影 + SH→FL 办件编排)。 本文档是契约与运维权威:写清"v2 与 v1 差在哪、双方字段闭集、故障状态表、broker 隔离的实值来源、 可信关联与恢复时序、撤权竞态的生效边界"。代码事实以本文为准,与代码分叉时先改文档再改代码。

依赖:TASK_1《consumer-access-v3.md》的 FormConsumerAccessService(owner/read/subscribe 与 grantVersion)是唯一授权事实源;本契约不写第二套 ACL,只消费它。

现行主链(2026-10-22 决策,10-23 复核):SH 以签名收据 + HTTP 确认建件,不依赖 MQ 快照 投递行、broker confirm 或 MQ 消费成功;requireRead + requireSubscribe 仍是可信上下文的授权前提。 下文 outbox/MQ 章节描述可选推送通道,不能据此把 MQ 重新设为建件必选项。 MQ 隔离、push_allowed 全量契约与投递状态覆盖修复另案交付,不能由本轮 FL 门禁变绿推定已完成。


1. 协议版本差额(先说清"新增了什么、旧的那条怎么算")

维度 v1(既有,冻结) v2(本任务新增)
exchange fc.events.v1(topic,广播) fc.answer.delivery.v2(topic,按消费方私有 routing key)
消息形状 十字段闭集、不含值文档(FcEventPublisher.Envelope) SnapshotEnvelope 十五字段,含完整 snapshot
版本键 version = 钉住的 schema 版本 formVersion(schema 版本)+ answerRevision(对外快照单调修订号)
可靠性 afterCommit 尽力发、失败仅 warn、"拉取即事实源" 答卷同事务落 outbox,afterCommit 只唤醒扫描;publisher confirm + mandatory return
消费方判据 广播 key 用资源 app_code routing key 用显式 consumerAppKey + 租户域:fc.snapshot.v2.<appKey>.<schoolId>
授权 无(能连 broker 就能看到元数据) 逐订阅授权 + 每条带 grantVersion,发布前复核
目标端点 无(消费方靠拉取) 新增 POST /oapi/form/submission-context、GET /oapi/form/submission-context/{intentId}/receipt、POST /oapi/form/answer-delivery/receipt、POST /oapi/form/answer-delivery/grants/verify

v1 一条都不改:字段闭集、routing key 形态、失败静默的取舍全部保留(FcEventPublisherTest 的十字段断言仍然成立)。差异登记见 §9 待修清单第 3 条。

2. 快照修订号 answerRevision

answer 新增 answer_revision BIGINT NOT NULL DEFAULT 0:创建=1,此后每个会改变对外快照的路径 在同事务内 +1,并与不可变 outbox 行一起落库。覆盖点(一处不漏):

路径 位置 事件类型
提交(手工/代录/导入/hosted) AnswerService#doSubmit created
本人编辑值 AnswerReadService#editBySubmitter updated
管理员编辑值 AnswerAdminService#doEdit updated
改业务状态(含批量) AnswerAdminService#doChangeStatus updated
软删(本人 + 管理面 + 批量) softDeleteBySubmitter / doSoftDelete tombstoned

并发控制是两道闸,缺一都可能让"同一 revision 对应两份内容":

  1. 行级 CAS:AnswerRepository#compareAndSetRevision(id, expected, next)(JPQL UPDATE 自带行锁; 必须在改动实体字段之前调用,否则自动 flush 会先把新号写进去、CAS 自我拒绝);
  2. outbox 唯一键 uk_delivery_revision(answer_uuid, answer_revision, consumer_app_key, consumer_school_id):CAS 没挡住的极端形态在这里撞键,整事务回滚。 撞键被翻译成 BizException(CUSTOM),故意不透传 DataIntegrityViolationException—— 提交链路外层会把 DIVE 当"幂等重放"返回原答卷,那等于把并发丢更新伪装成重放成功。

没上 JPA @Version:那会让存量的分离实体 save() 路径(并发 IT、批量导入)突然开始抛 OptimisticLockException,属于跨批次行为变更,不在本任务授权范围内。

3. 规范化与哈希:fc.canon.v1(跨端契约,两端各一份实现)

实现:form-center AnswerCanonicalizer、svc-hall AnswerProjectionStore(消费端复算)。 规则闭集(要改语义就升算法号,不许原地改):

  1. 对象键按 UTF-16 代码元字典序(String.compareTo)升序递归输出——钉比较器而不是"码点序", 两端都是 JVM,同一个比较器才逐字一致;
  2. 数组顺序保留(多选/排序是业务语义);
  3. 数字 decimalValue().stripTrailingZeros().toPlainString()(100.0→100、1E+2→100,无指数);
  4. 字符串只转义引号、反斜杠与控制字符(\b\f\n\r\t 短转义,其余 C0 用反斜杠+u+00+两位大写十六进制), 非 ASCII(含中文)原样输出,哈希取 UTF-8 字节;
  5. 空值策略:显式 null 成员保留(缺失键与 "k":null 是两种事实,合并会丢"字段被清空"这次变化);
  6. 快照顶层 intent 成员不参与哈希(它只对单个 consumer 可见,留着就会出现"同 revision 两份 hash");
  7. 时间恒为 yyyy-MM-dd'T'HH:mm:ss(不用 ISO_LOCAL_DATE_TIME:整分时它会省掉秒段,跨端就比不了);
  8. 投递时间、重试计数、消费端扩展列都是信封/本地列,不进快照。

跨端夹具(逐字比对,两端测试钉同一串)

输入快照(十个成员):schId=5, appCode="demo", code="honor_apply", submitterId=1001, subjectId=7, status="processed", values={"a":{"z":1,"y":null},"b":[2,1],"n":100.0,"e":true}, answerNo=null, createTime="2026-10-21T10:00:00", updateTime=null

规范串(FcAnswerDeliveryTest.CANONICAL_VECTOR = ShAnswerFlowTest.CANONICAL_VECTOR):

{"answerNo":null,"appCode":"demo","code":"honor_apply","createTime":"2026-10-21T10:00:00","schId":5,"status":"processed","subjectId":7,"submitterId":1001,"updateTime":null,"values":{"a":{"y":null,"z":1},"b":[2,1],"e":true,"n":100}}

contentHash = 上述字节串的 SHA-256 小写十六进制(64 位)。 消费端复算不一致 ⇒ 毒载荷拒绝;与已存投影行不一致 ⇒ 冲突待核验不覆盖(§5)。

信封示例(闭集十五字段;intent 只在 snapshot 内、只对目标 consumer 出现)

{"protocolVersion":"fc.answer.snapshot.v2","eventId":"5f0a…","eventType":"updated",
 "sourceAppKey":"100021","consumerAppKey":"100018","consumerSchoolId":5,"grantVersion":7,
 "formUuid":"f0e1…","formVersion":4,"answerUuid":"a1b2…","answerRevision":3,"isDeleted":false,
 "contentHash":"<64hex>","occurredAt":"2026-10-21T10:05:00+08:00",
 "snapshot":{"schId":5,"appCode":"demo","code":"honor_apply","submitterId":1001,"subjectId":1001,
   "status":"processed","values":{},"answerNo":null,"createTime":"…","updateTime":"…",
   "intent":{"intentId":"svchall-0123…","consumerAppKey":"100018","consumerSchoolId":5,
             "submitterId":1001,"formVersion":4,"linkedRevision":1}}}

不进快照的:anon_ip/anon_ua(审计数据,契约 v2 C8/C12)、库内 id(契约 §1)、附件字节 (值里只有 {id,fileName,key},P34)。无订阅的简单表单不产生任何投递行,答卷照常提交。

4. 可信提交上下文(A.1~A.4)

SH POST /api/apply/intents(body 只有 siteCode/itemCode/clientKey)
  └ 会话取人与校 → 事项入口判定:已发布 ∧ applyUrl 为空 ∧ onlineFlag=1
     ∧ TASK_1 成对绑定(fcFormUuid+fcFormVersion) ∧ flowGuid 就绪   ← 任一不过:不建件、不调流程
  └ 落 fc_apply_intent(uk: 校/人/事项/clientKey)
  └ 签名 POST /oapi/form/submission-context(intentId=svchall-<自然键哈希>)
      FC:resolveSignedCaller → requireRead + requireSubscribe
          → 按 (formUuid,formVersion) 精确取版本行 + 表单当前开放填报
          → embedOrigin / callbackUrl 取服务端配置(缺键即拒,浏览器 URL 一律不采信)
          → 同 (appKey,intentId) 回到同一行;令牌在该行上轮换(旧令牌即失效)
          → 存 SHA-256 摘要 + 过期 + 绑定坐标(默认 30 分钟)
  └ 返回 fillPageUrl = {FC 前端基址}/f/{code}?mode=embed&appCode=…&ctx=<token>

被嵌页(form-center front Fill.vue,hosted 通道)
  └ GET /api/form/hosted/schema?contextToken → 按令牌钉的那一版取 schema(绝不回落当前发布版)
  └ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken)
      FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域,
      错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1)
      → 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 满足推送条件的消费方才同事务写 outbox
      → 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝
  └ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin**
     (不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用)

SH POST /api/apply/intents/{id}/confirm("可确认"信号只触发这一步)
  └ 签名 GET /oapi/form/submission-context/{intentId}/receipt
      FC:只向所属消费应用返回 + 读取时再核一次 requireRead
      → PENDING(还没答卷,不建件) / SUBMITTED(坐标+提交者+revision+hash+快照)
  └ 逐字复核 consumerAppKey / 学校 / 提交者 / formUuid+formVersion → 建件(一次)

bizId/answerId 在浏览器可提交的形状里根本没有字段位——"不能用浏览器传入 bizId 直接建件" 是结构事实,不是审查约定。匿名与普通提交仍是中心自用的两条既有通道;hosted 首批只允许登录本人, 匿名答卷的 submitter_id=0 与上下文断言的真人对不上,"匿名完成消息用于本人办件"这条路径 在 redeem 处就被结构性挡住。

5. 投影、inbox 与顺序无关合并(A.5/B.5)

三张表各管一件事,没有第五张"双轨表":

表 唯一键 管什么
fc_apply_intent (sch,submitter,item,clientKey)、(consumerAppKey,fcIntentId)、answer_uuid、apply_id 业务意图;"一次意图一个办件"的库级形态
fc_answer_projection (answer_uuid, sch_id)、intent_id 答卷在本校的唯一投影 + 意图↔答卷↔办件的独立关联表(保存 formUuid/formVersion/answerRevision/flowGuid)
fc_answer_inbox (event_id, consumer_app_key) 每封事件怎么处置的台账(APPLIED/IGNORED/CONFLICT/DEFERRED/REJECTED)+ 回执上报状态

规则(AnswerProjectionStore#applySnapshot,一个事务内完成,DB 提交成功才 manual ack):

  • 更高 revision 才应用;更低忽略;同 revision 同 hash 幂等;同 revision 异 hash ⇒ conflict_flag=1 留证不覆盖(投影与台账同时记,等人工/对账核验);
  • 墓碑只是"更高 revision 的一种取值",本地保留 revision;旧 created 复活不了它;
  • biz_ext_json 是业务端扩展列,中心快照永不写;
  • 每条事件不回查答案值文档(全文就在消息里);应用结果经 §4 的成对签名端点回执, FC 按 eventId + 消费方 + revision 逐字复核并幂等记录;
  • 没有已验证意图就不建件:事件先到 ⇒ 只落 UNLINKED 投影;确认先到 ⇒ 意图行落 ANSWERED/APPLIED;后到的一方撞进同一组唯一键,两条路径收敛到同一个 intent/answer, 不会各建一个办件。

6. broker 隔离配置(实值来源,缺一条就别开 RABBITMQ_HOST)

v2 快照带完整值文档,"能连上 broker 的任何人可见"是不可接受的。以下每一项都是部署契约的 待填实值,不是建议:

项 实值来源 必须为真的判据
vhost projman 注入 RABBITMQ_VHOST(form-center 与 svc-hall 同一 vhost,与其他租户隔离) 该 vhost 内不存在任何非授权消费者可绑的队列;跨应用共享 vhost 上不得发明文快照
发布方凭据 RABBITMQ_USER/PASS(form-center 侧) 账号权限:仅对 fc.answer.delivery.v2 有 write,对 configure 只覆盖自身 exchange 声明
消费方凭据 svc-hall 侧同名 env 只能 configure/write/read 自己前缀的队列与 binding:svc-hall.fc.answer.snapshot.v2*、fc.snapshot.v2.<自身 appKey>.*;禁止 # 或他人前缀的通配绑定
队列与死信 FcAnswerMqConfiguration 声明 svc-hall.fc.answer.snapshot.v2(durable)+ .dlq(有界重试 3 次后不 requeue 落此,可查)
confirm/return form-center application.yml:publisher-confirm-type=correlated、publisher-returns=true、template.mandatory=true 拿不到逐条 confirm 就不发送(宁可积压 PENDING,也不按未确认投递)
隔离能力确认 运维在交付单上逐项签字 上述任一项不成立时,v2 全量快照不得启用;此时只保留"签名收据"这条建件路径(本机联调即该形态)

7. 故障状态表(两侧 + 恢复;按码处置,不靠文案)

7.1 form-center 投递行 answer_delivery.state

状态 含义 进入条件 离开
PENDING 已落库待发 答卷事务内入队;或未配 broker 时长期停留 扫描取出→发送
BROKER_ACCEPTED 总线收下 confirm ack=true 终态(业务侧结论看 consumer_applied_at)
FAILED 发送异常/不可路由/broker nack AmqpException、mandatory return、ack=false 退避 20s×2^n(base/cap 可配)后重扫
DEAD 有界重试耗尽 retry_count ≥ max-retries(8) 人工/failedPage 处置
GRANT_REVOKED 发布前 grant 复核未过 订阅撤销或 grantVersion 进位 终态,不重放旧 grant

两个时刻分列:broker_accepted_at(只由 confirm 回调写)与 consumer_applied_at + consumer_outcome(只由消费方签名回执写)。broker 的 ack 永远不构成业务成功证据; 回执自称 APPLIED 但 hash 与信封不符 ⇒ 记 CONFLICT;同 eventId 重复上报不同结论 ⇒ 保留首次。

7.2 svc-hall inbox state / report_state

APPLIED / IGNORED(低 revision 或同版同 hash 重放)/ CONFLICT(同版异 hash,不覆盖)/ DEFERRED(授权无法确认,暂缓写入)/ REJECTED(闭集或哈希不过,毒载荷 ack 后留档); report_state=PENDING/SENT/FAILED 由重报扫描追平(本地已落库但中心没收到确认时, 只补确认,不回滚事实)。

7.3 svc-hall flow_apply_command.state

状态 进入 处置
PENDING 与办件同短事务落库 事务外发送(HTTP 不占长事务)
UNKNOWN 任何发送异常(超时/断连/4xx/5xx 一律算未知) 先 GET /oapi/flow/instance 按业务键查
DONE 发起成功,或查得远端 10/20/30/40 并回填真实状态 终态
FAILED 退避中(含"流程中心未接入",等接入后自愈) next_retry_at 到点重扫
DEAD 重试预算耗尽(默认 6 次) 人工处置;不无限重投

关键取舍:不靠异常类型猜"远端有没有受理"。查到 30/40 只回填镜像状态、绝不重发 apply (重发会命中中心 R4 重启,把已拒绝/已撤回的件重新跑起来);只有查无才用同一 requestToken 补发。

7.3a flow-center 管理面绑定失败(2026-10-23 R2/R1)

关联表单的流程定义必须绑定 formUuid + formVersion;保存显式关联、发布与启用都按 schema?schId=&uuid=&version= 核验同一明确发布版本。UUID-only 定义没有长期回退,缺版本必须重新选择。 独立无表单流程保留;孤立版本号拒绝。FC 精确 schema 只允许已发布过的版本(publish_time != null), 拒绝草稿;历史发布版不要求是 currentVersion,也不改变历史答卷读取路径。

FL 信封稳定码:46101 VERSION_REQUIRED、46102 INVALID_COORDINATE、 46103 SCHEMA_UNAVAILABLE(可重试)、46104 SCHEMA_CONTRACT_VIOLATION、 46105 RESOURCE_UNAVAILABLE、46106 ACCESS_DENIED。除 46103 外均不能盲目重试。 FC schema 业务码 2 将不存在/不可读/未发布统一拒绝,FL 映射为 46105,不按文案探测资源存在性。 详见 FL 精确版本契约。

7.4 flow-center 发起守卫分支(FlowApplyRetryModels.ApplyResultV2.branch,稳定码)

码 判据 是否重启流程
CREATED 无既有实例 新建 + START 推进
IDEMPOTENT 命中 10/20 否(既有语义逐字不变)
SAME_REQUEST requestToken 与行上相同 否——同一次命令的重投
STALE_ROUND 请求 attempt ≤ 行 apply_round(NULL 按 1) 否
REPLAY_ONLY retryOnly=true 命中既有实例(含 30/40) 否
RESTARTED 更高 attempt + 新 token 命中 30/40 是,走既有 R4
CONCURRENT_CREATED 唯一键撞键后事务边界外重读命中 否(且先核归属)

归属复核三条:行 apply_app_key、submitter_id、流程线 flowGuid 与请求任一明确不符即拒, 不把别的应用相同 bizType+bizId 的办件当自己的结果。存量行(token/round 为 NULL)的守卫缺省是关。

7.5 时序

提交点(答卷事务)→ revision+1 → outbox 行 → [提交] → afterCommit 唤醒扫描 → 发送(persistent)
   ├ broker ack      → broker_accepted_at            (≠ 业务成功)
   ├ broker nack     → FAILED + 退避
   └ 不可路由/异常    → FAILED + 退避 →(耗尽)DEAD
消费方:manual ack 只在 DB 提交后;签名回执 → consumer_applied_at / consumer_outcome
进程崩溃窗口:outbox / flow_apply_command 都是库内行 → 重启后扫描续跑(不依赖内存闭包)

8. TASK_3 必须一次交付的扩展点(签名即契约,后续只调用不编辑)

form-center:AnswerSnapshotService.currentAuthorized(ConsumerPrincipal, Long consumerSchoolId, String answerUuid)、 AnswerDeliveryService.replayAuthorized(ConsumerPrincipal, String answerUuid, Long expectedRevision, String operationId) (稳定码 QUEUED/ALREADY_DELIVERED/GRANT_REVOKED/REVISION_MISMATCH/NO_DELIVERY_ROW, operationId 幂等吸收)、AnswerDeliveryService.failedPage(ConsumerPrincipal, Long, Integer, Integer)。 svc-hall:普通消费保留 AnswerProjectionStore.applySnapshot(SnapshotEnvelope);对账修复与分页改用下方§8.1明确的 repairAuthoritative(...)、manifestPage(...)(只出轻量摘要与本地创建时间,不拉值文档)、 retireAbsent(AuthoritativeAbsenceProof)(只做退役标记,不物理删、不改 revision; expectedRevision 与本地不符时拒绝退役并留证)。 快照适配与对账共用 §3 的 canonicalization/hash 契约,两端不得各写一套。

8.1 Task2 前置修正:对账与普通消费分离(2026-10-21)

  • 普通 applySnapshot 继续按MQ规则处理,同revision异hash不覆盖,且可能关联意图并建件;TASK_3不得使用它执行修复。
  • 新 RepairResult repairAuthoritative(SnapshotEnvelope envelope, Long expectedRevision, String expectedHash, Integer expectedIsDeleted, String proofId) 是服务端内部入口。TASK_3须先经平台签名的FC任务端点读取当前快照并核验task/consumer/school/form/answer/version/grant;proofId仅为审计引用,不是签名或浏览器可提交的授权凭据。使用现有fc.canon.v1复算hash,并绕过MQ授权缓存重新核验订阅且grantVersion必须相等。
  • expectedRevision/hash/isDeleted是本地读取的完整三元组;全部null仅表示观察到缺失。已有行用单条数据库条件更新检查三元组与school/form/answer仍匹配,更新仅限快照列与修复留证,保留bizExt/intent/apply/flow;缺失行插入UNLINKED投影。修复不调用建件、不写inbox或消费回执、不改意图。唯一键竞争须回滚并重新读取。
  • SH投影新增本地 projection_version 乐观锁列(与FC revision无关);权威条件更新同时递增该列,已读取旧实体的MQ/收据写入将触发乐观锁冲突,不能把权威修复覆盖回旧值。冲突必须整笔回滚,在事务外重新读取后有界重试。已有TASK_2库先执行 svc-hall/server/sql/add_fc_projection_version.sql,新库直接使用更新后的 fc_projection_and_apply_intent.sql,两脚本不可重复套用;本次未执行数据库升级。
  • 权威修复信封eventId最多36字符,可用任务/答卷操作的UUID;proofId最多128字符。新鲜授权响应必须同时匹配form/consumer/school及相等grantVersion,不能用别校/别应用响应或未来纪元放行。
  • 稳定返回:APPLIED、LOCAL_CHANGED(期望已变化,重新读取后有界重试)、LOCAL_AHEAD(本地revision更高,向FC复核且不降级)、DEFERRED(新鲜授权核验不通过,不写入)。同revision异hash仅在此权威入口可修复,普通MQ规则不变。
  • ManifestPage manifestPage(Long schId, String formUuid, String cursor, Integer size) 使用answerUuid升序keyset,初始cursor=null,按返回nextCursor继续;size默认200/上限500,多读一条确定completed。返回 {rows,nextCursor,completed};rows为answerUuid/revision/contentHash/isDeleted/schId/createTime,结束时completed=true且nextCursor=null,不暴露库内id。
  • 本地分页不提供一致性快照证明。TASK_3须保存扫描结果/检查点,并用完整FC物化manifest、新鲜缺席证明及任务起点校验退役候选;本地createTime为空或晚于任务起点时不可退役。分页中途新增/变动不得被“没看到”解释为中心不存在。
  • 新增规则门禁 ShReconcileExtensionTest;MySQL实际条件更新/唯一键并发、签名任务端点及Rabbit运行期行为仍需联调验证,规则单测不替代它们。

9. 待修清单(本轮刻意不修;跨校那条必须在本任务联调验收前解决)

  1. 跨校授权缺"调用方属于该租户域"的事实源(TASK_1 遗留,优先级最高)。 FormConsumerAccessService 判的是"(formUuid, consumerSchoolId, consumerAppKey) 三元键上有没有生效 grant",而 consumerSchoolId 一直是调用方申报的:grant 创建侧只限制"校管理员只能授给本校", 超管可代任意校授,于是"这个 appKey 到底能不能在 school=6 下消费数据"没有第二重证明。 本任务的可信建件把这条链放大成写侧风险——SH 申报的是事项所在校,中心照单核。 修向(择一,需授权):FC 侧建立 appKey↔可服务学校集合的事实源(UserBase 注册信息), 或把 grant 创建限定为"消费方应用注册校"。本轮按边界要求不改 TASK_1 的 ACL/目录文件。
  2. 托管填报的资格口径两侧不同:SH 入口判"事项发布 + 在线办理 + FC 绑定",FC 仍按表单 audience 门判"这个会话身份能不能填这张表"。若中心侧表单人群配得比事项服务对象严 (例如只含教师),学生会话会出现"能打开被嵌页、提交被 403"。修向:要么 SH 入口镜像 audience, 要么 hosted 通道把资格判据换成"意图授权"。本轮按任务书口径保留 audience 门不动 (它同时是 FC 自用通道的判据,改它要单独授权)。
  3. v1 与 v2 的事件口径分叉:改业务状态现在只产生 v2 的 updated 投递, v1 fc.events.v1 仍不发状态变更事件(v1 的 type 闭集与消费方约定都在冻结范围外)。 需要显式决定"v1 冻结并声明"还是"v1 同步补发",否则仍依赖 v1 的旧消费方看不到 status 变化。
  4. 应急对账闸不认识新命令表:FlowReconcileJob 仍只看 service_apply.flow_status, 不知道 flow_apply_command 的 UNKNOWN 行(该文件构造器被基线测试逐位钉住,本轮不动)。 大面积事故时开闸对账与恢复扫描可能同时推进同一条链路,需要后续统一到一个处置出口。
  5. 回调"更高版本 ⇒ 新一轮"的判定还没与 attempt 映射对齐(任务书 C.4 后半句,本轮未做): SvcFlowCallbackHandler#advance 仍按 instanceVersion > last_instance_version 认定新轮, 而本轮新增的持久轮次在 flow_apply_command.attempt 上,两侧没有交叉核对。 当前实际影响有限(R4 重启只由用户显式重提触发,且 markResubmitted 会清空旧轮结果), 但"版本更高"在严格口径下不自动证明是新一轮——需要回调侧按 intent/attempt 映射复核后再放行。 本轮按"不顺手改既有接收器"处理,留待与 §9#4 一并在统一处置出口里做。

10. 未实现范围与证据边界(照实说)

  • 问卷类独立后端未定位:本文 §3~§7 是可直接执行的消费协议与适配要求(信封、canonicalization、 inbox/投影唯一键、回执、撤权竞态)。未宣称"三端之外所有消费者已接入",也不虚构该消费端源码。
  • 部门实施冻结:新办件首次发起只做既有规则的成员就绪检查;部门来源切换、未来节点 冻结/改派/挂起、当前科室停用行为细则不在本任务(按已知限制记录,不声称实现 D10 全部细则)。
  • 前端只做到构建级验证(vite build 两条绿),本机浏览器通道不可用 ⇒ 没有 iframe 视觉证据; "消息来源三重校验(精确 origin + event.source + proto/v)"目前只有代码与协议级依据。
  • 测试是规则单测(FC 21 / SH 26 / FL 12 条,全绿)。桩事务能证"答卷写与 outbox 写在同一事务回调内、 回调抛出后两者都不落地、afterCommit 只唤醒扫描",证不了 MySQL 的原子性; manual ack、死信路由、publisher confirm 的 broker 行为在本机无 rabbit 的情况下未跑通。 这两类都要在联调环境补做,才算 §6/§7 的完整验收。
  • DDL 三个文件(form-center/service/sql/answer_delivery_v2.sql、 svc-hall/server/sql/fc_projection_and_apply_intent.sql、 flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql) 都是非幂等脚本,生产需人工执行;ddl-auto=update 的开发库会自动追加,两侧不能长期分叉。

11. R3/R4 对账结果回执裁决(2026-10-23 收口)

本节是对账 submitResult 协议,不修改 MQ publisher confirm/return 或投递状态机(B-09 另案)。 回执新增 decision、reportAttempt(本次回报轮次)、resultState(本轮已接受的结论); 既有 attempt 继续表示中心当前派发轮次,taskState 表示中心任务状态,不能单凭它推断本次旧回报被采纳。 旧回报永不推进中心新轮。终态权威必须由 CENTER_TERMINAL 显式声明,不以 stale=true 附带的诊断状态冒充。

条件 decision FC 写入 SH 处置
当前 attempt 首次合法结果 CURRENT_ACCEPTED 持久结果;RUNNING 续租,终态按已有规则收敛 按 resultState 回填;RUNNING 保留租约,失败不能变成功
当前 attempt 重复已终结结果 CURRENT_DUPLICATE 不覆盖首次结果、不重复计数 使用首次 resultState;不采信重报自己宣称的新结果
旧 attempt,中心尚非终态 STALE_RETRY 仅归档;已有终结结果不覆盖;不续新轮租约、不推进任务 清本地租约,QUEUED 等重派/重试;不把诊断 taskState 当本轮成功
中心已终态(包括旧 attempt 回报) CENTER_TERMINAL 不改中心终态与新轮结果 身份、轮次回声验证通过后镜像中心真实终态;FAILED/PARTIAL/CANCELLED 不伪装 COMPLETED
回报超出已派发 attempt HTTP 业务拒绝 零结果/状态变更 保留可重试/人工诊断,不记成功
发送异常、不可达、拒绝、空或不匹配回执 无有效确认 不推定已成功 QUEUED + pendingReportState,仅重报;有界退避,耗尽 FAILED
缺新 decision 的旧中心回执且 stale=true 保守兼容 — 保持 QUEUED,不信任附带终态;下一次完整回执再收敛

当前/重复成功回执必须匹配 taskId,reportAttempt 若提供必须等于本地发送 attempt。 新协议 current 决策不得确认其他轮次;CENTER_TERMINAL 的中心 attempt 不得低于本地执行轮。 乱序旧回执在本地持久化时仍受 work_version 乐观锁保护,不覆盖重派或新执行轮。 有效租约 RUNNING 的重派不改变执行 attempt、租约、检查点或 work_version;过期(含相等)后才能重新排队。 接收与领取均用同一任务行的悲观锁;领取时重新核对状态和退避时刻,陈旧扫描候选不能双领。