# 答卷投递 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..` | | 授权 | 无(能连 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`): ```json {"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 出现) ```json {"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= 被嵌页(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 精确版本契约](../../../flow-center/service/docs/form-binding-contract.md)。 ### 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;过期(含相等)后才能重新排队。 接收与领取均用同一任务行的悲观锁;领取时重新核对状态和退避时刻,陈旧扫描候选不能双领。