Files
form-center-service/docs/answer-delivery-v2.md
T
chenxing d7473112b0
build / build-push (push) Successful in 1m26s
t1~t3落地+10-22精修: 消费授权v3(owner/grant/订阅/目录)+答卷投递v2(可信提交上下文/快照/幂等关联)+答卷对账v1(清单/派发/租约/结果)
含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。
合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
2026-10-22 10:02:57 +08:00

301 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 答卷投递 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,只消费它。
---
## 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`):
```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=<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.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` 的开发库会自动追加,两侧不能长期分叉。