diff --git a/docs/answer-delivery-v2.md b/docs/answer-delivery-v2.md
new file mode 100644
index 0000000..05dd74c
--- /dev/null
+++ b/docs/answer-delivery-v2.md
@@ -0,0 +1,300 @@
+# 答卷投递 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..` |
+| 授权 | 无(能连 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.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` 的开发库会自动追加,两侧不能长期分叉。
diff --git a/docs/answer-reconcile-v1.md b/docs/answer-reconcile-v1.md
new file mode 100644
index 0000000..5337bf3
--- /dev/null
+++ b/docs/answer-reconcile-v1.md
@@ -0,0 +1,364 @@
+# 答卷差异对账 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 配置」 |
diff --git a/docs/consumer-access-v3.md b/docs/consumer-access-v3.md
new file mode 100644
index 0000000..1572bfd
--- /dev/null
+++ b/docs/consumer-access-v3.md
@@ -0,0 +1,117 @@
+# 表单中心 · 消费应用授权与表单目录(v3,TASK_1)
+
+对外契约的增补件,挂在《表单中心契约 v2》§9(对外读接口)之下。落地范围=**发现(目录)+ 授权(read/subscribe)+ 稳定引用(uuid+version)**,
+不含部门选型、不含 Apply 输入换轨(TASK_2)、不含事件投递(TASK_3)。
+
+依据:VERIFICATION V03/V04/V07;原骨架 T01 与 T05 的确切版本读取部分。
+
+---
+
+## §1 调用身份:只有一个来源
+
+`/oapi/form/**` 的身份 = 平台 `AppAuthFilter` 验签通过后写在请求属性 `yh.app.auth` 上的 **appKey**。
+
+- 三件套 `X-App-Key / X-Timestamp / X-Signature` 缺失 → 平台链 401(`enforce-patterns=[/oapi/*]`),进不到业务层;
+- 属性拿到了但密钥不可用,或 `OpenApiAppClient.getAppByKey` 不能在UserBase确认同一appKey → `UNAUTHORIZED`;
+ trusted-apps只提供验签密钥,不充当注册事实;注册查询不可达也拒绝,不把降级放行写成可用性保证;
+- 查询参数 `appCode` 与 `schId` **只是资源筛选与租户域**,不构成身份、不授予拥有权;
+- `app_registry` 仍是快应用目录(四业务列、零角色),本批一行不加,也不因 slug 同名自动认领任何资源。
+
+## §2 落型:资源拥有者与消费租户分开
+
+| 表 | 键 | 语义 |
+|---|---|---|
+| `form_ownership` | `uk_ownership_form(form_uuid)` | 这张表单是哪个 appKey 的资产;`form_school_id` 存绑定时表单自己的 `sch_id`(0=平台级) |
+| `form_consumer_grant` | `uk_grant(form_uuid, consumer_school_id, consumer_app_key)` | 允许"这个租户的这个应用"**读**和/或**订阅**这张表 |
+
+- **默认拒绝**:`read_allowed` / `subscribe_allowed` 默认 0,没有隐式全集;
+- **读与订阅分权**:owner 天然能读自己那张表的 schema/答卷,**订阅必须另开一行 grant**(能看 ≠ 能往外推);
+- **租户维度不可省**:只按 appKey 授权会丢掉"读答卷仍以该校行集限定"这条。平台级表单(`form.sch_id=0`)对某校授予后,
+ 那一校看到的答卷仍只有自己的行(行集由调用侧 `schId` 条件保证,授权不放宽它);
+- **owner 由管理面写**:校管理员/超管在写入前核验 appKey 的 UserBase 注册身份(§1 同一张注册表,只判存在、不回显密钥),
+ 平台级表单的 owner 绑定**仅超管**可设——否则校管理员可以把平台资源认领给自己应用;
+- **统一拒绝口径**:跨校 / 缺归属 / 不存在 / 未授权 → 同一句 `表单不存在:`,真实原因只进服务端 warn 日志;
+ `answer` 端点被拒时回答 `答卷不存在:`(答"表单不存在"等于同时确认两张对象都存在);
+- **撤销不删行**:`enabled=0` + `grant_version` 进位;重申同样是新纪元(版本再进位)。
+
+DDL:`sql/form_consumer_access_v3.sql`(无物理外键,`form_uuid` 为逻辑引用;两表零存量、无需回填)。
+
+## §3 目录端点与既有端点的授权
+
+```
+GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize=
+→ data = { items:[{formUuid, code, name, appCode, version, audience[]}], total }
+```
+
+- 过滤链:`sch_id ∈ (schId, 0)` ∩ `is_deleted=0` ∩ `status=已发布` ∩ **调用方可读**(owner ∪ 显式 read grant),
+ **先过滤再分页**——`total` 是过滤后的条数,页数不随授权抖动;
+- `page` 从 1 起算(缺省 1),`pageSize` 缺省 20、**上限 100**(超限直接拒,不静默收窄);
+- `keyword` 对 `name`/`code` 做大小写不敏感的包含匹配,`appCode` 精确匹配(仅筛选);
+- `version`=当前发布指针,目录只出已发布行所以必非空。消费方选中后**成对保存** `formUuid + version`;
+- 不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是两个都合法的可选项(遮蔽规则属于按 code 的 resolve 入口)。
+
+既有三个读端点同一批过 `requireRead`(签名通过不再等于全开):
+
+| 端点 | 判定时机 |
+|---|---|
+| `GET /oapi/form/schema?uuid=[&version=]` | 解析**之前**(手里有 uuid,被拒与不存在同句) |
+| `GET /oapi/form/schema?code=[&appCode=]` | 解析之后、返回载荷之前(code 形态先要拿到 uuid) |
+| `GET /oapi/form/answer?answerId=` | 定位答卷之后,拒绝时回答"答卷不存在" |
+| `GET /oapi/form/answers?form_uuid=` | 游标解析之前 |
+
+CAS 自用填报路径不受影响:服务端应用 ACL 不替代填报入口的会话身份与行级数据范围(契约 v2 §8 那套仍在会话身份上)。
+
+## §4 消费方(svc-hall / flow-center)保存什么
+
+- 两端各出一个**同源目录代理**(管理面调用,浏览器不持 appKey/secret):校域由 CAS 会话派生,不由前端传;
+- 只保存**服务器验证过的** `formUuid` + `formVersion`:不信自由输入的 `appCode`,也不信前端送来的版本号;
+- flow-center 发布期调 `schema?uuid=&version=`,**返回坐标与请求不一致即阻断发布**;已发布快照不改写,改关联走新版本;
+- svc-hall 事项保存时远端校验失败即整次保存失败(不写半个绑定)。
+
+## §5 与旧默认开放行为的差异(上线必读)
+
+改造前:`/oapi/form/**` 只要**应用签名通过**,对声明域(`schId` 本校 ∪ 平台级)内的 schema/answer/answers **默认全开**,
+没有表单级 ACL,也没有目录端点。改造后:**没有任何 owner 绑定或 grant 的表单,对外一律不可读**——
+包括此前"签名就能读"的存量表单。
+
+⇒ **部署前必须逐张补 `form_ownership`(中心自有表)或 `form_consumer_grant`(跨应用消费)**,否则现有消费链路会读不到表。
+这是本批刻意选择的 fail-closed 取向(默认拒绝优先于兼容),不做"未配置=放行"的过渡开关。
+
+**已知会在升级当场受影响的一条现成链路**:flow-center 的流程定义**发布期**快照(`FormCenterSchemaClient`,
+C1 第一刀)现在就在校域内按 uuid 读 `/oapi/form/schema`。中心升级后若没有给 flow-center 的 appKey(联调态 100019)
+补 owner 或 read grant,**发布会被拒**(表现为"快照拉取失败")。上线顺序因此固定为:
+① 中心侧先建好绑定/授权(按消费应用逐个授 read,flow-center 与 svc-hall 各一条起)→ ② 中心发布 → ③ 消费方发布。
+svc-hall 侧本次是新增链路(改造前不读中心),所以只有"选不到表"这一种表现,不影响存量事项。
+
+其余对外可见差异:
+1. 新增 `GET /oapi/form/forms`(纯增量,不改变任何既有响应形状);
+2. `schema`/`answer`/`answers` 的**成功响应逐字节不变**,只是多了一层拒绝;
+3. 拒绝文案统一到"表单不存在/答卷不存在",原先"未发布/停用"这类差别信息只在**通过读权判定之后**才会给出。
+
+## §6 管理面端点(守卫=校管理员 ∪ 超管,与 app 注册同一把)
+
+```
+GET /api/form/admin/form-access/owner?formUuid=&schId= → {formUuid, formSchoolId, ownerAppKey} | null
+PUT /api/form/admin/form-access/owner?formUuid=&schId= body {ownerAppKey}
+GET /api/form/admin/form-access/grants?formUuid=&schId= → [{formUuid, consumerSchoolId, consumerAppKey,
+ readAllowed, subscribeAllowed, enabled, grantVersion}]
+POST /api/form/admin/form-access/grants?schId= body {formUuid, consumerSchoolId, consumerAppKey,
+ readAllowed, subscribeAllowed}
+POST /api/form/admin/form-access/grants/revoke?schId= body {formUuid, consumerSchoolId, consumerAppKey}
+```
+
+- 校级管理员的管理域=本校:`consumerSchoolId` 不等于会话域即拒;超管可代某校授权/换绑;
+- `consumerSchoolId` 必须 ≥1(0 是资源域不是租户);`readAllowed`/`subscribeAllowed` 至少开一把;
+- appKey 写入前必须能在平台密钥注册表查到(否则"绑一个不存在的身份"会把资源挂到空气上),**响应不回显任何密钥**。
+
+## §7 后续任务的唯一入口(不许另起一套 ACL)
+
+`FormConsumerAccessService` 是授权判定的唯一出口:
+
+- `ConsumerPrincipal resolveSignedCaller(String appKey)`(`ConsumerPrincipalResolver`)
+- `void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
+- `SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
+- `List listEnabledSubscriptions(String formUuid, Long answerSchoolId)`
+
+TASK_2(Apply 输入换轨)与 TASK_3(事件投递)只调这四个接口;`SubscriptionGrant.grantVersion` 必须随投递下发,
+消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。**
diff --git a/docs/schema-extension-draft.md b/docs/schema-extension-draft.md
new file mode 100644
index 0000000..2e209fb
--- /dev/null
+++ b/docs/schema-extension-draft.md
@@ -0,0 +1,145 @@
+# 公共 schema 扩展契约草案(TASK_4)
+
+日期:2026-10-22。状态:**文档检查完成,候选功能未实施**。本文不批准SUR-T19/SUR-T20完整功能,不取消OPEN-T19b代提交;不修改校验器白名单、题型注册、统计或流程引擎。普通表单、匿名、自填与原有代填能力继续按现有代码解释。托管SH首批仅本人,是独立的既有边界。
+
+## 1. 证据基线与当前实现
+
+本次重新读取各子仓库HEAD及当前工作区,未创建提交:
+
+| 项目 | HEAD |
+|---|---|
+| FC后端 | `b2437a20742bfbfe38a564134904f3e0d7d931a3` |
+| FC前端 | `2e406a88b1d9e09b2ca54a8cf68a3726174f49c0` |
+| FL后端 | `2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f` |
+
+HEAD不包含Task1~3及本轮精修的未提交内容。下列公共校验常量/函数锚点从当前源码重新摘录;FL快照字段搬运也与`git show HEAD:...FormCenterSchemaClient.java`对照。改动过的管理/提交路径引用当前工作区,并在根目录`docs/tasks/evidence/refinement-20261022/`留源文件指纹,不能把工作区源码宣称为HEAD中已实现。
+
+FC / `src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java` / `FormSchemaValidator#validateSchema`、`#validateField`:
+
+```java
+if (!root.isObject() || !root.path("groups").isArray()) {
+ throw fail(null, "表单结构必须是 {\"groups\":[…]}");
+}
+rejectUnknownProps(group, ALLOWED_GROUP_PROPS, "分组属性");
+rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
+```
+
+这三句分属根对象、group循环、field方法,不是连续源码。根对象当前只检查对象及groups形状,**没有统一拒绝全部根级未知属性**;这不意味着未知规则会执行。group仅允许key/label/fields;field允许以下19项:
+
+```text
+key,label,type,required,pattern,min,max,maxLength,tip,placeholder,options,
+rows,defaultValue,accept,maxSize,maxFiles,precision,unit,searchable
+```
+
+FC / 同文件 / `FormSchemaValidator#validateOptions`:inline选项对象仅label/value;字典引用仅source且匹配`dict:`。字段valueSchema由读接口生成,不属于设计写入白名单。visible_when、matrix及任意脚本属性都不是已实现契约。
+
+FC / 同文件 / `FormSchemaValidator#PHASE1_TYPES`:
+
+```java
+public static final Set PHASE1_TYPES = new LinkedHashSet<>(Arrays.asList(
+ "text", "textarea", "number", "date", "select", "radio", "upload",
+ "checkbox", "multiselect", "phone", "image"));
+```
+
+address/user/users虽在14类冻结命名中,但未在一期放行。FC前端 / `src/types/formSchema.ts` / `PHASE1_TYPES`、`ALLOWED_FIELD_PROPS`及group/option/ref常量与之对应;`src/components/fields/index.ts` / `fieldWidget`、`widgetTypes`注册11类,app端组件仍有槽位限制,不能仅按目录存在就声明移动端完整可用。
+
+FC / `src/main/java/com/yunhai/formcenter/common/FormValueValidator.java` / `FormValueValidator#validate`:
+
+```java
+rejectUnknownKeys(data, fields);
+if (isEmptyValue(value)) {
+ if (isRequired(field)) {
+ throw fail(field, key, "为必填项,未填写");
+ }
+ continue;
+}
+validateValue(field, key, value, dictCandidates);
+```
+
+当前没有先执行visible_when的步骤。FC / `common/FormValueSchemaBuilder.java` / `withValueSchemas`在深拷贝上增加机读值形状,不改变版本schema原文;number输出number与边界,未生成precision对应multipleOf;多选输出array,不虚构uniqueItems;附件items闭集为id/fileName/key。机读片段不是完整的执行引擎替代物。
+
+## 2. 扩展分类(未来候选,不是今天的兼容承诺)
+
+| 类别 | 候选承载方式 | 未来识别策略 | 当前事实 |
+|---|---|---|---|
+| 不可执行display metadata | 独立版本化命名空间,如`extensions.display`,只放纯文本/枚举/布局提示 | 明确允许的读写链路原样保留;不参与资格、值校验、路由、hash派生规则;所有展示转义 | 当前field/group未知属性仍拒绝,设计器会按白名单序列化;没有通用保留保证 |
+| 可见性、必填联动、复合值约束等安全规则 | 明确schema契约版本及required capabilities | 写入/发布/提交/编辑/导入遇到不支持的必需规则必须拒绝,不能静默忽略 | 没有扩展能力协商或visible_when执行 |
+| 生成的机读描述 | valueSchema及未来投影说明 | 从已核验schema派生,不能作为客户端绕过后端校验的权威 | valueSchema当前仅在读取时生成 |
+
+不使用eval、Function、JS脚本、任意表达式字符串或可调用URL。候选规则采用受限JSON AST,字段引用只能指向本表schema中的稳定key;运算符、类型、深度、节点数、引用数需白名单和预算。下列形状只是评审样例,**现有字段白名单不会接受它**:
+
+```json
+{"key":"detail","type":"text","required":true,
+ "visible_when":{"op":"eq","field":"kind","value":"other"}}
+```
+
+将显示元数据和安全规则混放、遇到未知规则当作true、根据浏览器传来的visible结果跳过后端校验,均不属于候选契约。
+
+## 3. 可见性、必填与隐藏值
+
+未来统一顺序:按答卷formUuid/version取schema → 校验规则版本/类型/依赖 → 依拓扑顺序重算可见性 → 处理隐藏值策略 → 仅对可见字段执行required及值约束 → 附件归属/索引/存储。循环引用、未知key、类型错误、预算超限必须失败,不能转成“隐藏”。前端计算只用于体验,后端重算才是依据。
+
+| 可见性结果 | required | 输入 | 候选处理 |
+|---|---|---|---|
+| visible | true | 缺失/null/类型约定的空值 | 拒绝必填空值;0与false不能当空值 |
+| visible | false | 空 | 允许约定空值;是否缺键规范化为null须写入版本协议 |
+| visible | 任意 | 非空 | 执行该题类型及值域校验 |
+| hidden | 任意 | 无值 | 跳过required,不误报漏填 |
+| hidden | 任意 | 有值 | 必须先定稿下表A/B策略,当前不选择、不实施 |
+| 规则无效/结果未知 | 任意 | 任意 | 拒绝,返回稳定规则错误,不当作hidden |
+
+| 隐藏值候选 | A:拒绝携带 | B:服务端剥离 |
+|---|---|---|
+| 用户改上游答案使下游隐藏 | 需要客户端清理后重交;错误可诊断 | 服务端规范化后接收,须告知哪些字段被移除 |
+| 审计 | 记录拒绝字段名及规则版本,不把敏感values写日志 | 记录剥离字段名及规则版本,不能伪装成用户主动清空 |
+| 幂等/hash | 需明确同一请求的完整输入一致性 | 必须先规范化再生成存储hash,并区分原始请求指纹 |
+| 本人/管理员编辑 | 不允许通过隐藏字段偷偷补值 | 不允许以管理员权限跳过规则而无单独协议 |
+| 历史数据 | 不在读详情时破坏性重写旧答卷 | 同样不在读详情时自动剥离历史值;需要显式编辑/迁移审计 |
+
+A/B是实施前的业务决策,不能在开发中默认选一种。条件依赖于另一个隐藏字段时,该字段是缺失、历史保留还是不适用,也须一并定稿;禁止靠真假值隐式强转决定。
+
+## 4. 普通题型与版本来源基线
+
+| 项目/类#方法 | 当前值/版本事实 | 对扩展的边界 |
+|---|---|---|
+| FC `common/FormValueValidator#validateNumber` | JSON number,支持min/max、precision;number空值用null,不能用空字符串代替 | scale/矩阵不能借number类型塞数组或对象 |
+| 同类 `#validateSingleOption` | select/radio为候选value字符串,存value而非label | 条件比较需比较稳定value,label变化不改历史语义 |
+| 同类 `#validateMultiOption` | checkbox/multiselect为value字符串数组;逐项查候选,当前无显式去重拒绝 | 数组不得被FL按标量比较;不宣称JSONSchema有uniqueItems |
+| 同类 `#validateAttachments`,`AnswerService`/`AnswerAdminService`附件归属检查 | upload/image为[{id,fileName,key}],服务端另核归属、key和大小/数量;值中不存URL | 隐藏附件的剥离、引用释放、历史下载权限须成对设计 |
+| FC `service/AnswerService#doSubmit` | 登录普通提交按code/appCode解析发布版本;匿名走resolvePublishedForAnon,实际版本由服务端解析,不接受浏览器指定版本 | 未来普通提交须明确页面解析到写入之间的升版策略;不把hosted的钉版保证推到全部通道 |
+| 同方法hosted分支,前端`views/Fill.vue#loadForm/#handleSubmit` | contextToken确定formUuid+formVersion,CAS确定本人及学校;后端按上下文读取确切schema | 扩展也必须按token版本,不能按中心最新schema计算可见性 |
+| FC `service/AnswerImportService#publishedFields/#importAnswers` | 字段取当前发布版,并逐行走AnswerService.submit;并非已冻结整次导入的schema快照;upload/image列属于UNSUPPORTED_TYPES,不支持导入附件 | 未来需把预检与实际导入钉在同一版本,并与自填/编辑共用规则;不得把当前导入声称覆盖附件 |
+| FC `service/AnswerReadService#myDetail/#editBySubmitter` | 按answer.formUuid/version读schema、编辑校验 | 不因当前表单升级而重解释旧答卷可见性 |
+| FC `service/AnswerAdminService#edit` | 按答卷原版本校验并重建索引 | 管理员编辑不能另写显隐判据 |
+| FC `service/AnswerExportService#fieldsForVersion/#fieldsByVersion` | 列头按当前版字段,逐答卷版本取字段解释值,缓存按version;标签/字典经expandSchema解析 | 并非已按历史版本并集导出全部已删题;schema原文钉版不等于外部字典label永远冻结 |
+| FC `service/AnswerStatsService#stats/#groupField/#currentSchema` | 总数来自权限范围查询;字段元数据来自当前发布schema,按索引分组;没有题目可见性分母 | 不可把现有总数当作“看见该题人数”;跨版本同key统计须显式分组/映射 |
+
+表内路径均相对于form-center/service,前端另已标明。该表为源码基线,**本轮未执行上述题型的功能测试**。
+
+## 5. 统计、导出和历史语义候选
+
+未来分母至少分别表示:范围内答卷总数N、该题在其钉版schema存在的答卷E、规则计算可见的答卷V、有效作答A。缺失题/隐藏/可见未答/有效答案不能合并为一个null桶。V与A必须按每份答卷自己的schema版本和规则版本计算;不能把最新规则用于全部历史版本。
+
+- 创建/匿名创建/本人编辑/管理员编辑/导入:同一规则服务与相同的空值、隐藏值策略;写后索引、outbox和hash基于实际存储值,不能只在前端隐藏。
+- 统计:题目存在比例用E/N,作答率用A/V(V=0时明确无定义而非除零);多选按答卷计可选次数,不能把选项次数当人数。矩阵每行分母、整题分母分别标注,不跨版本暗合并。
+- 导出:按答卷版本给出字段/行列标识及不适用标记;隐藏值是“不适用”还是历史保留受A/B策略与权限共同决定。不得在导出过程中顺手改写答卷。
+- 编辑:按原版本重算可见性;如果获批升级答卷schema,必须独立迁移任务、有原/新版本及审计,不混入普通编辑。
+- 状态修改/软删:已有revision/outbox流程继续使用;规则版本与可见性派生数据是否改变快照/hash需后续协议显式升级,不能原地变更fc.canon.v1。
+
+## 6. 矩阵/量表候选
+
+未冻结matrix/scale题型,也未实现渲染、存储、校验或统计。若获批,先定义稳定rowKey/columnValue及整题显隐;不能靠每个单元格的动态隐藏解释同一复合值。
+
+```json
+{"satisfaction":{"row_service":"good","row_speed":"average"}}
+```
+
+上例仅为object值的候选;也可评审显式array结构,但须择一版本化,不能同时接受多种模糊编码。unknown row/column、重复row、必答行缺失、额外键、空行与不适用行的行为均须有真值表。复合值到FL必须拒绝作为标量路由,或经获批、显式、版本化的投影变成单一键/数值/枚举。
+
+## 7. 下一批实施与资源边界
+
+下批应成对分配FC校验器/前端白名单/渲染/提交编辑导入/统计导出与FL快照兼容,文件与任务拆分见FL伴随文档。候选预算可先评审:规则深度8、节点64、引用32,矩阵行列各50、单题2500单元格;这些是待定资源上限,不是当前代码保证。超限必须静态拒绝,不执行递归无界计算。
+
+实施前待定:A/B隐藏值策略、规则JSON版本、依赖隐藏字段语义、字典历史label、矩阵结构、投影命名/空值/值域及统计口径。未决项不影响本次只交付草案,但禁止据草案直接编码候选功能。
+
+验收状态:文档边界/样例/真值表交叉检查完成;FormSchemaValidatorTest等题型测试未运行(遵循本轮用户仅编译/打包要求)。本次相关后端打包只证明编译及构建,不能证明visible_when/matrix支持。
diff --git a/sql/answer_delivery_v2.sql b/sql/answer_delivery_v2.sql
new file mode 100644
index 0000000..0b7415f
--- /dev/null
+++ b/sql/answer_delivery_v2.sql
@@ -0,0 +1,105 @@
+-- ============================================================
+-- 表单中心 v2 · 快照投递与可信提交上下文 DDL(MySQL 5.7 兼容复核版,TASK_2)
+-- 范围:《answer-delivery-v2.md》§2/§3/§6 = answer.answer_revision 加列 + 建两张表
+-- answer_delivery(outbox)/ submission_context(可信提交上下文)
+-- 依据:任务书 TASK_2 B.1(单调修订号与不可变 outbox)、B.3(逐授权订阅一行)、
+-- A.2/A.3(令牌摘要与意图↔答卷一对一原子关联)
+-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由
+-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v3/t16 先例)。
+-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写
+-- CHARSET/COLLATE;③不建物理外键——answer_uuid / form_uuid 都是逻辑引用
+-- (答卷无物理删除但表单可软删,投递行与上下文必须能在其之后继续存在以支撑溯源);
+-- ④唯一键里的 answer_revision 是 BIGINT,不占 utf8mb4 长度预算。
+-- 身份口径:consumer_app_key / source_app_key 存平台 appKey(UserBase 注册分配、
+-- 验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug——
+-- 资源 appCode 当消费者会把"同应用多消费方"合并成一行(任务书 B.3 点名禁止)。
+-- 双时刻纪律:broker_accepted_at 与 consumer_applied_at 是两列、两个写入方,
+-- 前者只由 confirm 回调写、后者只由签名回执端点写;任何一侧都不许替另一侧作证(B.4/B.6)。
+-- 存量:answer.answer_revision 默认 0,不回填历史版本、不建双轨(任务书 A.5 末句)——
+-- 存量行此后第一次变更取号 1,此前的对外快照本来就没有 hash 事实,回填等于伪造历史。
+-- 非幂等:MySQL 5.7 无评审友好的 ADD/CREATE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。
+-- 执行前先 `SHOW TABLES LIKE 'answer_delivery'` / `'submission_context'` 确认不存在,
+-- 并 `SHOW COLUMNS FROM answer LIKE 'answer_revision'` 确认列不存在。
+-- ============================================================
+
+ALTER TABLE `answer`
+ ADD COLUMN `answer_revision` BIGINT NOT NULL DEFAULT 0
+ COMMENT '对外快照修订号:单调递增,凡改变对外快照的路径(创建/值编辑/改状态/软删,含批量)在同一事务内 +1 并写 outbox;与钉版 version 是两个维度';
+
+CREATE TABLE `answer_delivery` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `event_id` VARCHAR(36) NOT NULL COMMENT '信封事件 id(uuid):broker 相关键与消费端幂等键同一件',
+ `protocol_version` VARCHAR(32) NOT NULL COMMENT '固定 fc.answer.snapshot.v2(锁死 fc.canon.v1 哈希口径)',
+ `event_type` VARCHAR(16) NOT NULL COMMENT 'created / updated / tombstoned(值改与状态改同归 updated)',
+ `source_app_key` VARCHAR(64) DEFAULT NULL COMMENT '发布方应用身份(服务端配置,不接受消费方申报)',
+ `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '投递目标:显式消费应用平台 appKey',
+ `consumer_school_id` BIGINT NOT NULL COMMENT '投递目标租户域(与 appKey 一起构成授权坐标,不是资源归属校)',
+ `grant_version` INT NOT NULL COMMENT '入队时刻的授权纪元;发布前再核一次,进位或撤销即不重放',
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标(逻辑引用,不建 FK)',
+ `form_version` INT NOT NULL COMMENT '答卷钉住的 schema 版本(与 answer.version 同值)',
+ `answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid(对外身份,库内 id 不外露)',
+ `answer_revision` BIGINT NOT NULL COMMENT '本行对应的快照修订号(与 answer.answer_revision 同值)',
+ `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑标记;软删行的快照照常投递,靠它表达删除',
+ `content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 下核心快照(剔除 intent)的 SHA-256 十六进制小写',
+ `occurred_at` DATETIME NOT NULL COMMENT '业务发生时刻(= 答卷 update_time;投递时间不在此列)',
+ `snapshot_json` LONGTEXT NOT NULL COMMENT '不可变载荷:该消费方看到的完整快照(含仅对其可见的 intent 关联块)',
+ `state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING / BROKER_ACCEPTED / FAILED / DEAD / GRANT_REVOKED',
+ `retry_count` INT NOT NULL DEFAULT 0 COMMENT '投递元数据:重试计数不进快照、不参与哈希',
+ `next_retry_at` DATETIME DEFAULT NULL COMMENT '退避到点才再扫(base×2^n 封顶 1h),DEAD 置 NULL 不再自动重投',
+ `broker_accepted_at` DATETIME DEFAULT NULL COMMENT '总线 confirm(ack=true) 时刻——只证明总线收下,不证明业务应用',
+ `consumer_applied_at` DATETIME DEFAULT NULL COMMENT '消费方签名回执落定时刻(只有回执端点写得了)',
+ `consumer_outcome` VARCHAR(16) DEFAULT NULL COMMENT 'APPLIED / IGNORED / CONFLICT;重复上报不同结论时保留首次',
+ `last_replay_operation_id` VARCHAR(64) DEFAULT NULL COMMENT '最近一次授权重投的操作幂等键(同键不重复刷新退避)',
+ `last_error` VARCHAR(500) DEFAULT NULL COMMENT '末次失败原因或回执 localRef(截断存)',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_delivery_event` (`event_id`),
+ UNIQUE KEY `uk_delivery_revision` (`answer_uuid`, `answer_revision`, `consumer_app_key`, `consumer_school_id`),
+ KEY `idx_delivery_scan` (`state`, `next_retry_at`),
+ KEY `idx_delivery_consumer` (`consumer_app_key`, `consumer_school_id`, `id`),
+ KEY `idx_delivery_answer` (`answer_uuid`, `answer_revision`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='答卷快照 outbox(v2 §3:与答卷同事务落库、逐授权订阅一行、双时刻分列;不提供任何删除方法)';
+
+CREATE TABLE `submission_context` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用平台 appKey(取自已验签的 yh.app.auth,非请求体申报)',
+ `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(≥1;签发时即过 read+subscribe 两把授权)',
+ `intent_id` VARCHAR(64) NOT NULL COMMENT '消费方业务意图键(服务端生成;浏览器不可注入)',
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '钉定的表单 uuid(成对校验过版本行才落)',
+ `form_version` INT NOT NULL COMMENT '钉定的确切版本号(hosted 永不回落到当前发布版)',
+ `app_code` VARCHAR(64) NOT NULL COMMENT '解析出来的资源 appCode(提交时按它寻址,不再走 code 遮蔽)',
+ `code` VARCHAR(64) NOT NULL COMMENT '解析出来的表单 code(与 app_code 一起构成提交体坐标核对)',
+ `submitter_id` INT NOT NULL COMMENT '消费方服务端断言的填报人;兑换时与 CAS 会话逐字复核',
+ `token_digest` CHAR(64) NOT NULL COMMENT 'contextToken 的 SHA-256(明文不入库,只在签发响应里出现一次)',
+ `embed_origin` VARCHAR(200) NOT NULL COMMENT '可嵌入父页面精确 origin(服务端配置锁定,被嵌页 postMessage 的唯一目标)',
+ `callback_url` VARCHAR(500) NOT NULL COMMENT '消费方确认入口绝对 URL(服务端配置;浏览器传入的一律不采信)',
+ `expires_at` DATETIME NOT NULL COMMENT '有效期(默认 30 分钟,form-center.hosted-fill.token-ttl-minutes)',
+ `answer_uuid` VARCHAR(32) DEFAULT NULL COMMENT '答卷原子关联(提交事务内写;NULL=尚未填报。一个意图只挂一份答卷)',
+ `linked_revision` BIGINT DEFAULT NULL COMMENT '关联时刻的快照修订号(收据与投影合并的判据之一)',
+ `linked_at` DATETIME DEFAULT NULL COMMENT '关联时刻',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_ctx_token_digest` (`token_digest`),
+ UNIQUE KEY `uk_ctx_intent` (`consumer_app_key`, `intent_id`),
+ UNIQUE KEY `uk_ctx_answer` (`answer_uuid`),
+ KEY `idx_ctx_form` (`form_uuid`, `form_version`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='可信提交上下文(v2 §6:意图↔答卷一对一、令牌只存摘要、坐标与 origin/回调都在签发时锁死)';
+
+-- 自验(执行后逐条核对):
+-- SHOW CREATE TABLE answer_delivery\G -- 应含 uk_delivery_event / uk_delivery_revision 两个唯一键
+-- SHOW CREATE TABLE submission_context\G -- 应含 uk_ctx_intent(同意图不新建第二行)与 uk_ctx_answer
+-- SHOW COLUMNS FROM answer LIKE 'answer_revision'; -- 必须存在且 NOT NULL DEFAULT 0
+-- SELECT COUNT(*) FROM information_schema.columns
+-- WHERE table_schema=DATABASE() AND table_name IN ('answer_delivery','submission_context')
+-- AND column_name REGEXP 'secret|token$|retry_policy'; -- 必须 0 行(密钥与明文令牌都不入库)
+-- SELECT COUNT(*) FROM information_schema.referential_constraints
+-- WHERE table_name IN ('answer_delivery','submission_context'); -- 必须 0 行(无物理外键)
+-- SELECT COUNT(*) FROM answer WHERE answer_revision <> 0; -- 刚执行完必须 0(不回填历史)
diff --git a/sql/answer_reconcile_v1.sql b/sql/answer_reconcile_v1.sql
new file mode 100644
index 0000000..39a0fc3
--- /dev/null
+++ b/sql/answer_reconcile_v1.sql
@@ -0,0 +1,128 @@
+-- ============================================================
+-- 表单中心 · 差异对账任务 DDL(MySQL 5.7 兼容复核版,TASK_3)
+-- 范围:answer-reconcile-v1 §2/§3/§5 = 三张新表
+-- answer_reconcile_task(任务)/ answer_reconcile_manifest_row(不可变清单)/
+-- answer_reconcile_result(结果回报)
+-- 依据:任务书 TASK_3 改动点 1/2/4/8;不复用 answer_op_log 当任务表、不动 answer/answer_delivery。
+-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由
+-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v2/v3 先例)。
+-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
+-- ③不建物理外键——task_uuid 是逻辑引用,清单与结果要能在任务行之后继续存在以支撑复盘;
+-- ④清单游标与本地扫描游标同为 answer_uuid(32 位小写十六进制),general_ci 下与 binary 序一致。
+-- 身份口径:consumer_app_key 存平台 appKey(验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug。
+-- 审计纪律:三张表都没有删除路径(服务层与仓库层都不提供 delete),终态留表供复盘。
+-- 非幂等:MySQL 5.7 无评审友好的 CREATE TABLE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。
+-- 执行前先 `SHOW TABLES LIKE 'answer_reconcile%'` 确认三张表都不存在。
+-- ============================================================
+
+CREATE TABLE `answer_reconcile_task` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `task_uuid` VARCHAR(36) NOT NULL COMMENT '任务对外身份(服务端 UUID,≤36 字符;URL 与指令都用它)',
+ `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '对账目标的消费应用平台 appKey(不是 app_code slug)',
+ `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域:与投递行同口径,也就是答卷归属校',
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '本轮范围=一张表单;全量=按生效订阅 fission 成多行任务',
+ `form_version` INT NOT NULL COMMENT '物化时核验过的发布版本(缺席答复与修复信封的 formVersion 权威值)',
+ `trigger_type` VARCHAR(16) NOT NULL COMMENT 'SCHEDULE / MANUAL(列名避开 MySQL 保留字 TRIGGER)',
+ `operator` VARCHAR(64) DEFAULT NULL COMMENT '人工触发时的操作人(调度产生的为 NULL)',
+ `idempotency_key` VARCHAR(128) NOT NULL COMMENT '日期+订阅+范围唯一键:调度重复跑不出第二行任务',
+ `state` VARCHAR(16) NOT NULL DEFAULT 'QUEUED' COMMENT 'QUEUED/DISPATCHED/RUNNING/COMPLETED/PARTIAL/FAILED/CANCELLED',
+ `manifest_state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/MATERIALIZING/COMPLETE/FAILED:派发只在 COMPLETE 后',
+ `manifest_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '清单结束标记:只有它为 1,"清单里没有"才可能是权威缺席',
+ `total_rows` INT NOT NULL DEFAULT 0 COMMENT '物化完成后=清单行数(上限见 fc.answer.reconcile.max-rows)',
+ `total_pages` INT NOT NULL DEFAULT 0 COMMENT '确定总页(ceil(total_rows/page_size),不是"某页空了")',
+ `materialized_rows` INT NOT NULL DEFAULT 0 COMMENT '已写入清单行数(进度与断点续作的计数)',
+ `materialized_pages` INT NOT NULL DEFAULT 0 COMMENT '已物化页数',
+ `checkpoint_cursor` VARCHAR(32) DEFAULT NULL COMMENT '物化检查点:已写到的最后一个 answer_uuid(重启从这里续)',
+ `window_start` DATETIME NOT NULL COMMENT '任务起点=一致读事务开始时刻;晚于此刻的本地行不得判为多余行',
+ `window_end` DATETIME DEFAULT NULL COMMENT '清单物化完成时刻(窗口长度给运维复盘用)',
+ `grant_version` INT NOT NULL DEFAULT 1 COMMENT '物化时刻的授权纪元;每轮读取再核一次,进位即按撤权处置',
+ `attempt` INT NOT NULL DEFAULT 0 COMMENT '派发尝试次数(首派 0;租约超期或派发失败后 +1,有上限)',
+ `lease_until` DATETIME DEFAULT NULL COMMENT '派发/物化租约到期时刻:超期由扫描按 attempt+1 有限退避接手',
+ `next_dispatch_at` DATETIME DEFAULT NULL COMMENT '退避到点才再派(base×2^n 封顶),耗尽即 FAILED',
+ `dispatched_at` DATETIME DEFAULT NULL COMMENT '对端 2xx 收下指令的时刻(不代表执行完成,终态只看 result 回报)',
+ `finished_at` DATETIME DEFAULT NULL COMMENT '终态落定时刻',
+ `cancel_requested` TINYINT NOT NULL DEFAULT 0 COMMENT '取消请求:置 1 后清单/快照读取一律以取消口径答复,消费端据此停止剩余修复',
+ `error_code` VARCHAR(32) DEFAULT NULL COMMENT '稳定错误码(GRANT_REVOKED/RESOURCE_LIMIT/MANIFEST_INCOMPLETE/…)',
+ `error_detail` VARCHAR(500) DEFAULT NULL COMMENT '末次错误摘要(截断存;答卷值文档绝不进这里)',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_reconcile_task_uuid` (`task_uuid`),
+ UNIQUE KEY `uk_reconcile_task_idem` (`idempotency_key`),
+ KEY `idx_reconcile_dispatch` (`state`, `next_dispatch_at`),
+ KEY `idx_reconcile_consumer` (`consumer_app_key`, `consumer_school_id`, `id`),
+ KEY `idx_reconcile_coord` (`consumer_app_key`, `consumer_school_id`, `form_uuid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='差异对账任务(TASK_3 §2:一行=一个 (消费应用, 消费租户域, 表单) 坐标上的一次对账企图;不是投递行、不是答卷、不是 op_log)';
+
+CREATE TABLE `answer_reconcile_manifest_row` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `task_id` BIGINT NOT NULL COMMENT '逻辑引用 answer_reconcile_task.id(不建物理外键)',
+ `task_uuid` VARCHAR(36) NOT NULL COMMENT '任务身份冗余一份:清单行脱离任务上下文也要能自证是哪一轮',
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '物化时核验过的表单坐标',
+ `form_version` INT NOT NULL COMMENT '该答卷钉住的 schema 版本(= answer.version,不从旧清单猜版本)',
+ `answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid:清单游标与消费端本地游标同为它',
+ `revision` BIGINT NOT NULL COMMENT '物化时刻的对外修订号(含 0:存量未变更行原样入清单,修复端点会拒绝并只报告)',
+ `content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 摘要(与投递行同一条规范化实现,本表不复制算法)',
+ `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑也进清单:"缺席"与"存在但已删"是两个不同结论',
+ `seq_no` INT NOT NULL COMMENT '物化写入序号(审计与断点续作的序号锚;分页游标不是它)',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_manifest_row` (`task_id`, `answer_uuid`),
+ KEY `idx_manifest_page` (`task_id`, `answer_uuid`),
+ KEY `idx_manifest_seq` (`task_id`, `seq_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='任务不可变清单(TASK_3 §3:一致读事务内物化四元摘要含墓碑;此后分页只读它,全列 updatable=false,无 update/delete 方法)';
+
+CREATE TABLE `answer_reconcile_result` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `task_id` BIGINT NOT NULL COMMENT '逻辑引用 answer_reconcile_task.id',
+ `task_uuid` VARCHAR(36) NOT NULL COMMENT '任务身份(回报定位用)',
+ `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '回报来源:必须等于已验签身份,自报不符即拒',
+ `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(跨校回报的判据之一)',
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '范围坐标(每条错误与计数都能定位到这里)',
+ `attempt` INT NOT NULL COMMENT '对应的派发尝试;比最新 attempt 更旧的回报不推进状态(不倒退新 attempt)',
+ `state` VARCHAR(16) NOT NULL COMMENT 'COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报)',
+ `local_scan_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '消费端本地扫描是否整轮完成(多余行判定的前提之一)',
+ `manifest_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '消费端实际读到的中心结束标记(与中心不一致则本轮结论不成立)',
+ `scanned_rows` INT NOT NULL DEFAULT 0 COMMENT '比较过的答卷数',
+ `pages_processed` INT NOT NULL DEFAULT 0 COMMENT '处理页数',
+ `missing_repaired` INT NOT NULL DEFAULT 0 COMMENT '缺失修复数(repairAuthoritative=APPLIED,期望三元组全 null)',
+ `stale_repaired` INT NOT NULL DEFAULT 0 COMMENT '陈旧修复数(本地 revision 更低)',
+ `hash_conflicts` INT NOT NULL DEFAULT 0 COMMENT '同 revision 异 hash:记录后按中心当前值修复',
+ `tombstones_repaired` INT NOT NULL DEFAULT 0 COMMENT '墓碑修复数(同一权威修复原语,保留本地业务扩展)',
+ `local_ahead` INT NOT NULL DEFAULT 0 COMMENT '本地 revision 更高:只向中心复核,禁止降级覆盖',
+ `deferred` INT NOT NULL DEFAULT 0 COMMENT '授权核验不可达/撤权/纪元不等而暂停的条目',
+ `extra_reported` INT NOT NULL DEFAULT 0 COMMENT '只报告的多余行(含期间新增、createTime 未知、范围不明)',
+ `extra_retired` INT NOT NULL DEFAULT 0 COMMENT '经 FC 签名缺席证明实际退役的行数(无证明即不增)',
+ `error_count` INT NOT NULL DEFAULT 0 COMMENT '错误条目总数(超上限会截断,明细以 errors_json 为准)',
+ `errors_json` MEDIUMTEXT NOT NULL COMMENT '错误明细(有上限、截断存;每条含 consumer/task/range/answer 四坐标,不含 values)',
+ `resumed` TINYINT NOT NULL DEFAULT 0 COMMENT '1=进程重启后从检查点续跑的同一 attempt 再报',
+ `reported_at` DATETIME NOT NULL COMMENT '中心受理该回报的时刻',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_result_attempt` (`task_id`, `attempt`),
+ KEY `idx_result_task` (`task_id`, `attempt`),
+ KEY `idx_result_consumer` (`consumer_app_key`, `consumer_school_id`, `id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='对账结果回报(TASK_3 §5:(task_id, attempt) 唯一=幂等锚点;一 attempt 一行不覆盖,APPLIED 类计数不代表建件或流程成功)';
+
+-- 自验(执行后逐条核对):
+-- SHOW CREATE TABLE answer_reconcile_task\G -- 应含 uk_reconcile_task_idem(同日同订阅同范围只一行)
+-- SHOW CREATE TABLE answer_reconcile_manifest_row\G -- 应含 uk_manifest_row(同任务同答卷不重复入清单)
+-- SHOW CREATE TABLE answer_reconcile_result\G -- 应含 uk_result_attempt(重复回报幂等吸收的库级形态)
+-- SELECT COUNT(*) FROM information_schema.referential_constraints
+-- WHERE table_name IN ('answer_reconcile_task','answer_reconcile_manifest_row','answer_reconcile_result');
+-- -- 必须 0 行(无物理外键)
+-- SELECT COUNT(*) FROM information_schema.columns
+-- WHERE table_schema=DATABASE() AND table_name='answer_reconcile_result'
+-- AND column_name IN ('values_json','snapshot_json','secret','token');
+-- -- 必须 0 行(值文档与密钥不进对账留痕)
diff --git a/sql/form_consumer_access_v3.sql b/sql/form_consumer_access_v3.sql
new file mode 100644
index 0000000..7e16761
--- /dev/null
+++ b/sql/form_consumer_access_v3.sql
@@ -0,0 +1,64 @@
+-- ============================================================
+-- 表单中心 v3 · 消费应用授权 DDL(MySQL 5.7 兼容复核版,TASK_1)
+-- 范围:《consumer-access-v3.md》§2/§3 = 建两张表 form_ownership / form_consumer_grant
+-- 依据:VERIFICATION V03/V04/V07;owner 绑定与 grant 分权(读/订阅)、默认拒绝、撤销留痕
+-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate
+-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3/t10/t16 先例)。
+-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
+-- ③不建物理外键——form_uuid 对 form 是逻辑引用(同 t16 对 app_code 的处理:
+-- 表单软删是唯一切除形态且无物理删除,绑定/授权行必须能在表单软删后继续存在以支撑溯源,
+-- 建 FK 等于把"级联删"写进库侧规则);④无可空业务列(判权路径不写"未知=怎样"的分支)。
+-- 身份口径:owner_app_key / consumer_app_key 存的是平台 appKey(UserBase 应用注册分配,
+-- 验签三件套里那个 X-App-Key),不是 form.app_code 那种人读 slug。
+-- app_registry 仍是快应用目录,本批一行不加(v3 §1 红线:不把业务授权塞回注册表)。
+-- 租户口径:consumer_school_id 既是授权生效域,也是读答卷时的行集限定域;0 不是合法消费域
+-- (平台级是资源域,不是一个能答卷的租户),服务侧直接拒。
+-- 默认拒绝:两把 *_allowed 开关默认 0,enabled 默认 1(建行即生效,但建之前服务侧必须显式给开关);
+-- 撤销是 enabled=0 + grant_version 进位,不删行(判权 SQL 必须带 enabled 条件)。
+-- 非幂等:MySQL 5.7 无 ADD/CREATE IF NOT EXISTS 的评审友好写法(写了反而掩盖"表已存在但列不对"的漂移)。
+-- 执行前先 `SHOW TABLES LIKE 'form_ownership'` / `'form_consumer_grant'` 确认不存在;
+-- 存量:系统未上线,两张表零存量、无需回填(与 t16 同一论证)。
+-- ============================================================
+
+CREATE TABLE `form_ownership` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标:表单全局 uuid(契约 §1,不可枚举、跨版本不变)',
+ `form_school_id` BIGINT NOT NULL COMMENT '绑定时表单自己的 sch_id;0=平台级表单',
+ `owner_app_key` VARCHAR(64) NOT NULL COMMENT '资源拥有者应用的平台 appKey(写入前已核验 UserBase 注册身份)',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_ownership_form` (`form_uuid`),
+ KEY `idx_ownership_owner` (`owner_app_key`, `form_school_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='表单资源拥有者绑定(v3 §2:一张表一个 owner,只能由管理面写;form_uuid 为逻辑引用不建 FK)';
+
+CREATE TABLE `form_consumer_grant` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '被授权的表单 uuid',
+ `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户(授权生效域 = 读答卷的行集限定域,≥1)',
+ `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用的平台 appKey',
+ `read_allowed` TINYINT NOT NULL DEFAULT 0 COMMENT '读授权:目录/schema/answer/answers;默认拒绝',
+ `subscribe_allowed` TINYINT NOT NULL DEFAULT 0 COMMENT '订阅授权:答卷变更事件投递(owner 不天然持有)',
+ `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '生效开关;0=已撤销(保留行供审计与 grant_version 判新旧)',
+ `grant_version` INT NOT NULL DEFAULT 1 COMMENT '授权版本号:撤销与重申都进位,投递侧必须带它下发',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_grant` (`form_uuid`, `consumer_school_id`, `consumer_app_key`),
+ KEY `idx_grant_consumer` (`consumer_app_key`, `consumer_school_id`, `enabled`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='消费应用对某张表单的显式授权(v3 §2:三元唯一、读与订阅分权、默认拒绝、撤销不删行)';
+
+-- 自验(执行后逐条核对):
+-- SHOW CREATE TABLE form_ownership\G -- 应含 UNIQUE KEY `uk_ownership_form` (`form_uuid`)
+-- SHOW CREATE TABLE form_consumer_grant\G -- 应含 UNIQUE KEY `uk_grant` (三元键) 与 idx_grant_consumer
+-- SELECT COUNT(*) FROM information_schema.columns
+-- WHERE table_schema=DATABASE() AND table_name IN ('form_ownership','form_consumer_grant')
+-- AND column_name REGEXP 'secret|app_code|role|perm'; -- 必须 0 行(密钥与 app_code 都不进授权表)
+-- SELECT COUNT(*) FROM information_schema.referential_constraints
+-- WHERE table_name IN ('form_ownership','form_consumer_grant'); -- 必须 0 行(无物理外键)
diff --git a/src/main/java/com/yunhai/formcenter/config/AnswerDeliveryConfiguration.java b/src/main/java/com/yunhai/formcenter/config/AnswerDeliveryConfiguration.java
new file mode 100644
index 0000000..f758cd6
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/config/AnswerDeliveryConfiguration.java
@@ -0,0 +1,37 @@
+package com.yunhai.formcenter.config;
+
+import com.yunhai.formcenter.service.AnswerDeliveryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * 快照投递的运行期装配(answer-delivery-v2 §3.4,TASK_2 B.4)。
+ *
+ * {@code @EnableScheduling} 落在本类而不是应用主类:{@code FormCenterApplication} 不在本任务
+ * 写范围内,而 v2 的退避重投必须有周期扫描(broker 恢复后要把 PENDING/FAILED 追平)。
+ *
+ * 扫描不是持久性机制——持久性是 outbox 行本身(同事务落库)。这里只是把"到点的行"
+ * 推给 broker;{@code afterCommit} 的即时唤醒({@link AnswerDeliveryService#wakeScan()})负责低延迟,
+ * 本方法负责兜底。未配 broker 时 {@code sendDueBatchQuietly} 直接返回 0,行留在库里。
+ */
+@Slf4j
+@Configuration
+@EnableScheduling
+@RequiredArgsConstructor
+public class AnswerDeliveryConfiguration {
+
+ private final AnswerDeliveryService deliveryService;
+
+ /** 到点重投(退避序列由投递行的 next_retry_at 表达,这里只负责"闹钟") */
+ @Scheduled(fixedDelayString = "${fc.answer.delivery.scan-interval-ms:10000}",
+ initialDelayString = "${fc.answer.delivery.scan-initial-delay-ms:30000}")
+ public void scanDueDeliveries() {
+ int sent = deliveryService.sendDueBatchQuietly();
+ if (sent > 0 && log.isInfoEnabled()) {
+ log.info("[v2投递] 周期扫描已交出去 {} 条(等 broker confirm)", sent);
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java b/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java
new file mode 100644
index 0000000..11ceb0b
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java
@@ -0,0 +1,96 @@
+package com.yunhai.formcenter.config;
+
+import com.yunhai.formcenter.service.AnswerReconcileService;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * 差异对账的运行期装配(TASK_3,answer-reconcile-v1 §7)。
+ *
+ * 本任务的配置全部独立:键前缀 {@code fc.answer.reconcile.*},不写 {@code fc.answer.delivery.*}
+ * (TASK_2)、不写 {@code form-center.*}(TASK_1)的键,也不改那两个任务的任何文件。
+ * {@code @EnableScheduling} 早已由 {@link AnswerDeliveryConfiguration} 打开,本类只加自己的闹钟。
+ *
+ * 两个开关默认都是关的:{@code schedule-enabled}(每日建任务)与 {@code dispatch-enabled}
+ * (物化+派发循环)。未授权订阅不产生任务,而未开调度同样不产生任务——调度可显式关闭这件事
+ * 是任务书的前置条件,不是可选优化。 {@code targets} 是服务端登记的消费方后台地址表,
+ * 键=已验签的 consumerAppKey:派发地址只能从这里来,浏览器/请求体里出现的任何 callbackUrl
+ * 在本任务里没有对应字段,也就无从采信。
+ */
+@Slf4j
+@Configuration
+@RequiredArgsConstructor
+public class AnswerReconcileConfiguration {
+
+ private final AnswerReconcileService reconcileService;
+
+ @Bean
+ @ConfigurationProperties(prefix = "fc.answer.reconcile")
+ public AnswerReconcileProperties answerReconcileProperties() {
+ return new AnswerReconcileProperties();
+ }
+
+ /** 清单物化与派发共用一个扫描闹钟:每 tick 最多推进一条任务(这就是任务级单并发) */
+ @Scheduled(fixedDelayString = "${fc.answer.reconcile.scan-interval-ms:15000}",
+ initialDelayString = "${fc.answer.reconcile.scan-initial-delay-ms:45000}")
+ public void scanReconcileTasks() {
+ AnswerReconcileProperties properties = reconcileService.properties();
+ if (!properties.isDispatchEnabled()) {
+ return; // 默认停用:建任务与查看结果仍可用,但不物化、不派发、不修数据
+ }
+ int materialized = reconcileService.materializeNextQuietly();
+ int dispatched = reconcileService.dispatchNextQuietly();
+ int recovered = reconcileService.recoverExpiredLeasesQuietly();
+ if (materialized + dispatched + recovered > 0 && log.isInfoEnabled()) {
+ log.info("[对账] 本轮物化 {} 任务、派发 {} 任务、租约回收 {} 任务", materialized, dispatched, recovered);
+ }
+ }
+
+ /** 对账运行参数({@code fc.answer.reconcile.*}) */
+ @Getter
+ @Setter
+ public static class AnswerReconcileProperties {
+
+ // ===== 开关(默认全关,见类头)=====
+ private boolean scheduleEnabled = false;
+ private boolean dispatchEnabled = false;
+
+ // ===== 资源上限:超限拒绝建任务并留下原因,绝不当场无界载表 =====
+ /** 单任务清单行数上限 */
+ private int maxRows = 20000;
+ /** 单任务清单页数上限(与 maxRows 双闸:页大小被调大时它兜底) */
+ private int maxPages = 200;
+ /** 一次调度/一次全量人工触发最多裂变出多少任务 */
+ private int maxTasksPerRun = 20;
+ /** 清单分页与建议扫描页大小(消费端可在请求里收窄,上限 500) */
+ private int pageSize = 200;
+ /** 结果回报里最多带多少条错误明细、明细 JSON 最多多少字符 */
+ private int maxErrorItems = 50;
+ private int maxErrorChars = 8000;
+
+ // ===== 派发与租约 =====
+ /** 租约时长(秒):消费端要在这段时间内回报进度,否则按 attempt+1 重派 */
+ private int leaseSeconds = 900;
+ /** 有限退避重派次数(含首派);耗尽即 FAILED,留可查记录,不无限重投 */
+ private int maxDispatchAttempts = 4;
+ private int backoffBaseSeconds = 60;
+ private int backoffCapSeconds = 1800;
+ /** 消费方后台服务地址表:键=consumerAppKey(已验签身份),值=带 context-path 的完整基址 */
+ private java.util.Map targets = new java.util.LinkedHashMap<>();
+ /** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */
+ private String dispatchPath = "/oapi/form-sync/reconcile";
+
+ /** 信封 sourceAppKey:默认取本应用签名身份 */
+ private String sourceAppKey = "";
+
+ /** 扫描闹钟节奏(占位符与本类 @Scheduled 共用同一批键) */
+ private long scanIntervalMs = 15000L;
+ private long scanInitialDelayMs = 45000L;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAccessController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAccessController.java
new file mode 100644
index 0000000..98c4101
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAccessController.java
@@ -0,0 +1,111 @@
+package com.yunhai.formcenter.controller.api;
+
+import com.yunhai.formcenter.model.FormAccessModels.GrantRequest;
+import com.yunhai.formcenter.model.FormAccessModels.GrantView;
+import com.yunhai.formcenter.model.FormAccessModels.OwnerRequest;
+import com.yunhai.formcenter.model.FormAccessModels.OwnershipView;
+import com.yunhai.formcenter.model.FormAccessModels.RevokeRequest;
+import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
+import com.yunhai.formcenter.service.FormAccessPolicy;
+import com.yunhai.formcenter.service.FormAdminGuard;
+import com.yunhai.formcenter.service.FormConsumerAccessService;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.utils.model.BoaUser;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 消费应用授权管理面(v3 §2/§3,TASK_1)。
+ *
+ *
+ * GET /api/form/admin/form-access/owner → 这张表的资源拥有者(未绑定回 null)
+ * PUT /api/form/admin/form-access/owner → 设/换 owner 绑定(appKey 必须已在平台注册)
+ * GET /api/form/admin/form-access/grants → grant 清单(含已撤销行)
+ * POST /api/form/admin/form-access/grants → 授权/重申(读与订阅两把开关)
+ * POST /api/form/admin/form-access/grants/revoke → 撤销(enabled=0 + grantVersion 进位)
+ *
+ *
+ * 守卫沿用既有那把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③,
+ * 与 app 注册、管理员两表同域),不新造一套管理面鉴权。为什么不是"创建人也能改":
+ * owner/grant 决定的是别的能不能读这张表,属于平台管理域而不是单张表的定义权——
+ * 创建人能把表建起来、发出去,但不能替全校做"把答卷给外部应用看"这个决定。
+ *
+ * 身份核验的正向路径(v3 §2):写进来的 appKey 必须是 UserBase 注册过的应用
+ * ({@link ConsumerPrincipalResolver#isRegisteredAppKey} 查平台密钥注册表,只判存在、不回显密钥)。
+ * 签名应用没有自报 owner 的通道——这张表上没有"应用自己声明我拥有某张表单"的入口。
+ */
+@RestController
+@RequestMapping("/api/form/admin/form-access")
+@RequiredArgsConstructor
+public class FormAccessController {
+
+ private final FormAdminGuard adminGuard;
+ private final FormConsumerAccessService accessService;
+ private final ConsumerPrincipalResolver callerResolver;
+
+ /** owner 绑定(读) */
+ @GetMapping("/owner")
+ public OwnershipView owner(BoaUser user,
+ @RequestParam("formUuid") String formUuid,
+ @RequestParam(required = false) Long schId) {
+ return accessService.findOwnership(adminGuard.requirePlatformAdmin(user, schId), formUuid);
+ }
+
+ /** owner 绑定(写):归属校由服务端按表单自己的 sch_id 落,不信入参 */
+ @PutMapping("/owner")
+ public OwnershipView setOwner(BoaUser user,
+ @RequestParam("formUuid") String formUuid,
+ @RequestParam(required = false) Long schId,
+ @RequestBody OwnerRequest body) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ String appKey = body == null ? null : body.getOwnerAppKey();
+ requireRegisteredAppKey(appKey, "ownerAppKey");
+ return accessService.setOwner(domain, formUuid, appKey, FormAccessPolicy.isSuperAdmin(user));
+ }
+
+ /** grant 清单 */
+ @GetMapping("/grants")
+ public List grants(BoaUser user,
+ @RequestParam("formUuid") String formUuid,
+ @RequestParam(required = false) Long schId) {
+ return accessService.listGrants(adminGuard.requirePlatformAdmin(user, schId), formUuid);
+ }
+
+ /** 授权/重申 */
+ @PostMapping("/grants")
+ public GrantView grant(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @RequestBody GrantRequest body) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ requireRegisteredAppKey(body == null ? null : body.getConsumerAppKey(), "consumerAppKey");
+ return accessService.grant(domain, body, FormAccessPolicy.isSuperAdmin(user));
+ }
+
+ /** 撤销(不删行:留审计,grantVersion 进位让消费方认得出旧事件) */
+ @PostMapping("/grants/revoke")
+ public GrantView revoke(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @RequestBody RevokeRequest body) {
+ return accessService.revoke(adminGuard.requirePlatformAdmin(user, schId), body,
+ FormAccessPolicy.isSuperAdmin(user));
+ }
+
+ private void requireRegisteredAppKey(String appKey, String field) {
+ if (appKey == null || appKey.trim().isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, field + " 必填(UserBase 注册分配的 appKey)");
+ }
+ if (!callerResolver.isRegisteredAppKey(appKey)) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ field + "=" + appKey.trim() + " 不是平台已注册的应用身份,先在 UserBase 应用注册里核对 appKey");
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormSyncAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormSyncAdminController.java
new file mode 100644
index 0000000..8c72450
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormSyncAdminController.java
@@ -0,0 +1,117 @@
+package com.yunhai.formcenter.controller.api;
+
+import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage;
+import com.yunhai.formcenter.model.FormSyncModels.ReplayRequest;
+import com.yunhai.formcenter.model.FormSyncModels.ResultView;
+import com.yunhai.formcenter.model.FormSyncModels.TaskCreateRequest;
+import com.yunhai.formcenter.model.FormSyncModels.TaskView;
+import com.yunhai.formcenter.service.AnswerReconcileService;
+import com.yunhai.formcenter.service.FormAdminGuard;
+import com.yunhai.platform.utils.model.BoaUser;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 对账管理面(TASK_3 改动点 7,{@code /api/form/admin/sync/**})。
+ *
+ * 守卫与 grant 管理同一条:{@code requirePlatformAdmin}(校管理员 ∪ 超管),
+ * 校级管理员的可见与可操作范围都钉在本校域内(跨域寻址按"任务不存在"回答)。
+ * 这里没有任何"批量重发全部答卷"的入口——全量只能建对账任务由 Worker 逐差异修复,
+ * 单答卷才允许直接重放投递({@code replayAuthorized}),且执行前重新过一遍 ACL。
+ *
+ * 旧 outbox 日志不等于新答卷:{@code /deliveries/failed} 只是投递侧的失败/死信查看,
+ * 真正的差异修复走 {@code /tasks},两者不互相证明。
+ */
+@RestController
+@RequestMapping("/api/form/admin/sync")
+@RequiredArgsConstructor
+public class FormSyncAdminController {
+
+ private final FormAdminGuard adminGuard;
+ private final AnswerReconcileService reconcileService;
+
+ /** 建对账任务:ANSWER / FORM / ALL 三种范围;超限与范围不符落 FAILED 留痕行并回原因 */
+ @PostMapping("/tasks")
+ public List create(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @RequestBody TaskCreateRequest body) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.createManualTasks(user, domain, body);
+ }
+
+ /** 任务列表(进度与状态) */
+ @GetMapping("/tasks")
+ public Page list(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @RequestParam(required = false) String consumerAppKey,
+ @RequestParam(required = false) Long consumerSchoolId,
+ @RequestParam(required = false) String state,
+ @RequestParam(required = false) Integer page,
+ @RequestParam(required = false) Integer size) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.taskPage(domain, consumerAppKey, consumerSchoolId, state, page, size);
+ }
+
+ /** 任务详情(含最近一次回报摘要) */
+ @GetMapping("/tasks/{taskId}")
+ public TaskView detail(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("taskId") String taskId) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.taskDetail(domain, taskId);
+ }
+
+ /** 逐 attempt 的结果与失败明细(每条都能定位 consumer/task/range/answer) */
+ @GetMapping("/tasks/{taskId}/results")
+ public List results(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("taskId") String taskId) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.results(domain, taskId);
+ }
+
+ /** 取消任务:剩余投递与修复一律停(幂等,终态行不会被改写) */
+ @PostMapping("/tasks/{taskId}/cancel")
+ public TaskView cancel(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("taskId") String taskId) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.cancel(domain, taskId, user);
+ }
+
+ /** 单答卷重放(先重新 ACL 再执行;历史 revision 的明文快照不可重建,按稳定码回结论) */
+ @PostMapping("/replay")
+ public DeliveryStateView replay(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @RequestBody ReplayRequest body) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.replayAnswer(user, domain,
+ body == null ? null : body.getConsumerAppKey(),
+ body == null ? null : body.getConsumerSchoolId(),
+ body == null ? null : body.getAnswerUuid(),
+ body == null ? null : body.getExpectedRevision(),
+ body == null ? null : body.getOperationId());
+ }
+
+ /** 投递失败/死信分页(TASK_2 failedPage 的转发,不给它另拼一套查询) */
+ @GetMapping("/deliveries/failed")
+ public FailedDeliveryPage failedDeliveries(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @RequestParam String consumerAppKey,
+ @RequestParam Long consumerSchoolId,
+ @RequestParam(required = false) Integer page,
+ @RequestParam(required = false) Integer size) {
+ Long domain = adminGuard.requirePlatformAdmin(user, schId);
+ return reconcileService.failedDeliveries(domain, consumerAppKey, consumerSchoolId, page, size);
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/HostedFillController.java b/src/main/java/com/yunhai/formcenter/controller/api/HostedFillController.java
new file mode 100644
index 0000000..218ada4
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/HostedFillController.java
@@ -0,0 +1,99 @@
+package com.yunhai.formcenter.controller.api;
+
+import com.yunhai.formcenter.common.FormRuntimeRules;
+import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.entity.SubmissionContext;
+import com.yunhai.formcenter.enums.FormEnums;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.HostedFormView;
+import com.yunhai.formcenter.model.AnswerModels;
+import com.yunhai.formcenter.service.AnswerReadService;
+import com.yunhai.formcenter.service.AnswerService;
+import com.yunhai.formcenter.service.FormAccessPolicy;
+import com.yunhai.formcenter.service.FormDefService;
+import com.yunhai.formcenter.service.FormDictService;
+import com.yunhai.formcenter.service.SubmissionContextService;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.utils.model.BoaUser;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * hosted 填报面(answer-delivery-v2 §6.3,TASK_2 A.3):被业务应用嵌入时的取址与提交。
+ *
+ * 与中心自用填报页的三点不同,都在"钉版"这件事上:
+ *
+ * - 按上下文令牌取 schema,坐标是签发时锁定的 {@code formUuid+formVersion},
+ * 永不回落到当前发布版——升版窗口里在途的办件必须按当时那一版填,
+ * 否则收上来的值与办件绑定的坐标就不是同一张表;
+ * - 身份仍由 CAS 取:令牌只说"这次填报属于哪个意图",
+ * {@code schId/submitterId} 一律会话推导,且与令牌断言的人/校逐字比对(错人错校即拒);
+ * - 首批只允许登录本人:匿名与普通提交仍是 FC 自用的两条既有通道,
+ * 匿名答卷进不了这条链({@code submitterId} 对不上哨兵 0,见
+ * {@code SubmissionContextService#redeem})。
+ *
+ *
+ * 提交走 {@code POST /api/form/hosted/submit},请求体与 {@code /api/form/submit} 同形
+ * (多带一个 {@code contextToken});管线仍是 {@code AnswerService} 那一条,
+ * hosted 只是"多一道令牌复核 + 原子关联意图",不开第二份写库实现。
+ */
+@RestController
+@RequestMapping("/api/form/hosted")
+@RequiredArgsConstructor
+public class HostedFillController {
+
+ private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final SubmissionContextService contextService;
+ private final AnswerService answerService;
+ private final FormDefService defService;
+ private final FormDictService dictService;
+
+ /**
+ * 上下文钉住版本的 schema 与表单要点(令牌过期/错人/错校都拿不到,按上下文无效拒)。
+ */
+ @GetMapping("/schema")
+ public HostedFormView schema(BoaUser user, @RequestParam("contextToken") String contextToken) {
+ Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,无法打开托管填报页");
+ SubmissionContext ctx = contextService.redeem(contextToken, user, schId);
+ FormDefService.Published pinned = defService.resolvePublishedByUuid(schId,
+ ctx.getFormUuid(), ctx.getFormVersion());
+ Form form = pinned.form();
+ HostedFormView view = new HostedFormView();
+ view.setFormUuid(form.getUuid());
+ view.setAppCode(form.getAppCode());
+ view.setCode(form.getCode());
+ view.setName(form.getName());
+ view.setVersion(pinned.versionRow().getVersion());
+ view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
+ view.setFillLimit(form.getFillLimit());
+ view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
+ view.setDeadlineReached(FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now()));
+ view.setGroups(AnswerReadService.groupsNode(
+ dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson())));
+ view.setExpiresAt(ctx.getExpiresAt() == null ? null : TS.format(ctx.getExpiresAt()));
+ view.setEmbedOrigin(ctx.getEmbedOrigin());
+ return view;
+ }
+
+ /**
+ * hosted 提交(同一件事的两个入口形状,实现只有一条):请求体必须带 {@code contextToken},
+ * 由 {@code AnswerService} 在答卷事务内兑换、校验坐标并原子关联意图。
+ */
+ @PostMapping("/submit")
+ public AnswerModels.SubmitView submit(BoaUser user, @RequestBody AnswerModels.SubmitRequest body) {
+ if (body == null || body.getContextToken() == null || body.getContextToken().trim().isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "hosted 提交必须带 contextToken(托管填报入口不接收无上下文的提交)");
+ }
+ return answerService.submit(user, body);
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/AnswerDeliveryReceiptController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/AnswerDeliveryReceiptController.java
new file mode 100644
index 0000000..df75ded
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/oapi/AnswerDeliveryReceiptController.java
@@ -0,0 +1,73 @@
+package com.yunhai.formcenter.controller.oapi;
+
+import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReport;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReportView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantSnapshotView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantVerifyRequest;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.service.AnswerDeliveryService;
+import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.appauth.AppAuthFilter;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+
+/**
+ * 投递结果回执与授权核验端点(answer-delivery-v2 §5/§7,TASK_2 B.6 + 撤权边界)。
+ *
+ * 两条都是签名 HTTP,与 MQ 确认是两回事:
+ *
+ * - {@code POST /oapi/form/answer-delivery/receipt}——消费方业务落库后的结果。
+ * broker 的 confirm 只证明总线收下,绝不能代替它(B.4/B.6);{@code consumerAppKey}
+ * 以验签身份为准,请求体里自报的值不一致即拒;
+ * - {@code POST /oapi/form/answer-delivery/grants/verify}——轻量授权核验:按 formUuid 批量
+ * 回 {@code subscribeAllowed + grantVersion},不回查任何答案值文档。消费端带 TTL 缓存它,
+ * 缓存命中期间撤权的窗口由这条接口收敛;无法确认时消费端必须暂缓写入(v2 §5)。
+ * 判据仍然是 TASK_1 的 {@code requireSubscribe},这里不拼第二套 ACL。
+ *
+ *
+ * 路径在 {@code /oapi/*} 下,由既有 {@code RequireAppSignFilter} 强制应用签名。
+ */
+@RestController
+@RequestMapping("/oapi/form/answer-delivery")
+@RequiredArgsConstructor
+public class AnswerDeliveryReceiptController {
+
+ private final AnswerDeliveryService deliveryService;
+ private final ConsumerPrincipalResolver callerResolver;
+
+ /** 消费方应用结果回执(幂等:同 eventId+同结论重复上报返回原结论) */
+ @PostMapping("/receipt")
+ public AppliedReportView receipt(HttpServletRequest request, @RequestBody AppliedReport body) {
+ ConsumerPrincipal caller = caller(request);
+ if (body != null && body.getConsumerAppKey() != null
+ && !body.getConsumerAppKey().trim().equals(caller.getAppKey())) {
+ // 自报消费方与验签身份不符:这不是"参数不对",是冒充,按不匹配拒(回执本身不做存在性区分)
+ throw new BizException(ErrorCode.FORBIDDEN, "回执申报的 consumerAppKey 与已验签身份不符");
+ }
+ if (body != null) {
+ body.setConsumerAppKey(caller.getAppKey());
+ }
+ return deliveryService.recordApplied(body);
+ }
+
+ /** 批量授权核验(每条事件不回查值文档;有 TTL 缓存的消费端用它判"这封还作不作数") */
+ @PostMapping("/grants/verify")
+ public List grants(HttpServletRequest request, @RequestBody GrantVerifyRequest body) {
+ return deliveryService.grantSnapshot(caller(request),
+ body == null ? null : body.getConsumerSchoolId(),
+ body == null ? null : body.getFormUuids());
+ }
+
+ private ConsumerPrincipal caller(HttpServletRequest request) {
+ Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
+ return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/FormSyncTaskController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/FormSyncTaskController.java
new file mode 100644
index 0000000..8ce3044
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/oapi/FormSyncTaskController.java
@@ -0,0 +1,78 @@
+package com.yunhai.formcenter.controller.oapi;
+
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.model.FormSyncModels.ManifestPageView;
+import com.yunhai.formcenter.model.FormSyncModels.ResultAck;
+import com.yunhai.formcenter.model.FormSyncModels.ResultReport;
+import com.yunhai.formcenter.model.FormSyncModels.TaskAnswerReply;
+import com.yunhai.formcenter.service.AnswerReconcileService;
+import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
+import com.yunhai.platform.appauth.AppAuthFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 对账任务的消费端面(TASK_3 改动点 3,全部签名 HTTP)。
+ *
+ * 三条端点成对于 svc-hall 侧的 Worker:清单分页、任务快照答复、结果回报。
+ * 与 {@code OapiFormController} 隔离——那边是"按游标增量拉答卷"的数据面,
+ * 这一边是"某一轮对账的取证与回报",鉴权语境完全不同(这里必须核任务坐标与最新 grant)。
+ *
+ * 身份唯一来源仍是平台验签后写在 {@code yh.app.auth} 上的 appKey;
+ * 请求里申报的任何 consumerAppKey 都不采信,冒充任务目标即 {@code 403}。
+ * 每次读取都过一遍 {@code AnswerReconcileService} 的核验:任务存在、身份等于目标、
+ * 未取消、最新 grant 仍生效且纪元未进位——不因"建任务时有权"永久放行。
+ */
+@RestController
+@RequestMapping("/oapi/form-sync/tasks")
+@RequiredArgsConstructor
+public class FormSyncTaskController {
+
+ private final AnswerReconcileService reconcileService;
+ private final ConsumerPrincipalResolver callerResolver;
+
+ /** 清单分页:游标=上一页返回的 nextCursor(answerUuid),首次传空;结束只看 completed */
+ @GetMapping("/{taskId}/manifest")
+ public ManifestPageView manifest(HttpServletRequest request,
+ @PathVariable("taskId") String taskId,
+ @RequestParam(value = "cursor", required = false) String cursor,
+ @RequestParam(value = "size", required = false) Integer size) {
+ return reconcileService.manifestPage(caller(request), taskId, cursor, size);
+ }
+
+ /**
+ * 任务快照答复:修复入口的唯一权威来源。
+ *
+ * {@code expectedRevision} 只在"中心清单里没有这份答卷"时用于把缺席证明钉在消费端扫描到的
+ * 那一版上(消费端并发护栏);存在性结论与快照内容都来自中心当前值。
+ */
+ @GetMapping("/{taskId}/answers/{answerUuid}")
+ public TaskAnswerReply answer(HttpServletRequest request,
+ @PathVariable("taskId") String taskId,
+ @PathVariable("answerUuid") String answerUuid,
+ @RequestParam(value = "expectedRevision", required = false)
+ Long expectedRevision) {
+ return reconcileService.taskAnswer(caller(request), taskId, answerUuid, expectedRevision);
+ }
+
+ /** 结果回报:{@code (taskId, attempt)} 幂等,旧 attempt 不倒退新 attempt */
+ @PostMapping("/{taskId}/result")
+ public ResultAck result(HttpServletRequest request,
+ @PathVariable("taskId") String taskId,
+ @RequestBody ResultReport body) {
+ return reconcileService.submitResult(caller(request), taskId, body);
+ }
+
+ private ConsumerPrincipal caller(HttpServletRequest request) {
+ Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
+ return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java
index 51d7f18..5de199d 100644
--- a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java
+++ b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java
@@ -1,25 +1,37 @@
package com.yunhai.formcenter.controller.oapi;
+import com.yunhai.formcenter.model.FormAccessModels.CatalogPage;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.model.OapiModels;
+import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.OapiFormService;
+import com.yunhai.platform.appauth.AppAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import javax.servlet.http.HttpServletRequest;
+
/**
* 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的
* {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。
*
*
- * GET /oapi/form/schema?schId=&code=[&appCode=] → 现形态:data=内联 options + valueSchema 的机读 schema(契约 §4)
+ * GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize= → v3 目录(已发布 ∩ 可读,先过滤再分页)
+ * GET /oapi/form/schema?schId=&code=[&appCode=] → 现形态:内联 options + valueSchema 的机读 schema(契约 §4)
* GET /oapi/form/schema?schId=&uuid= → T11 新形态:该表单当前已发布版
* GET /oapi/form/schema?schId=&uuid=&version= → T11 新形态:精确版本行(快照核对/重放)
* GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9)
* GET /oapi/form/answers?schId=&form_uuid=&游标 → v2 增量拉取(契约 v2 §6.1,T17,见方法注释)
*
*
+ * 签名只给身份,不给权限(v3 §1/§3):本控制器先取平台验签后落在请求属性
+ * {@code yh.app.auth} 上的 appKey({@link ConsumerPrincipalResolver}),再交给服务层过
+ * {@code FormConsumerAccessService.requireRead}。查询参数 {@code appCode} 与 {@code schId} 一样
+ * 只是资源筛选/租户域,任何"自报应用"的写法都不构成授权。
+ *
* uuid 两形态的用途=给 flow-center 在流程定义发布期拉契约快照嵌进 def 版本行
* (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。
* uuid 与 code 同传时 uuid 优先(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口,
@@ -38,45 +50,72 @@ import org.springframework.web.bind.annotation.RestController;
public class OapiFormController {
private final OapiFormService formService;
+ private final ConsumerPrincipalResolver callerResolver;
/**
* 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态,
* 同传时 uuid 优先(见类头),{@code version} 只在 uuid 形态有意义=精确取版本行。
*/
@GetMapping("/schema")
- public OapiModels.SchemaView schema(@RequestParam("schId") Long schId,
+ public OapiModels.SchemaView schema(HttpServletRequest request,
+ @RequestParam("schId") Long schId,
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "appCode", required = false) String appCode,
@RequestParam(value = "uuid", required = false) String uuid,
@RequestParam(value = "version", required = false) Integer version) {
- return formService.schema(schId, appCode, code, uuid, version);
+ return formService.schema(caller(request), schId, appCode, code, uuid, version);
}
/** 单条答卷(钉住版本 schema 引用 + 值文档) */
@GetMapping("/answer")
- public OapiModels.AnswerView answer(@RequestParam("schId") Long schId,
+ public OapiModels.AnswerView answer(HttpServletRequest request,
+ @RequestParam("schId") Long schId,
@RequestParam("answerId") String answerId) {
- return formService.answer(schId, answerId);
+ return formService.answer(caller(request), schId, answerId);
}
/**
* v2 增量拉取(契约 §6.1/C9,T17)——事实源,MQ 事件只是降延迟。
*
*
- * GET /oapi/form/answers?schId=&formUuid=&after=answer_uuid[&limit=] 冻结表单:append-only + 墓碑
- * GET /oapi/form/answers?schId=&formUuid=&updated_since=ISO8601[|answer_uuid] 非冻结:updated 窗口
+ * GET /oapi/form/answers?schId=&form_uuid=&after=answer_uuid[&limit=] 冻结表单:append-only + 墓碑
+ * GET /oapi/form/answers?schId=&form_uuid=&updated_since=ISO8601[|answer_uuid] 非冻结:updated 窗口
*
*
* 两种游标按表单的冻结状态二分、不接受混用(拒绝文案点明这张表要哪种),{@code limit} 默认 500
* 上限 2000;响应 {@code {rows,nextCursor}},行字段是闭集且不含库内 id(V53)。
- * {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看)。
+ * {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看,
+ * v3 起还要另过 read 授权)。
*/
@GetMapping("/answers")
- public OapiModels.AnswersView answers(@RequestParam("schId") Long schId,
+ public OapiModels.AnswersView answers(HttpServletRequest request,
+ @RequestParam("schId") Long schId,
@RequestParam("form_uuid") String formUuid,
@RequestParam(value = "after", required = false) String after,
@RequestParam(value = "updated_since", required = false) String updatedSince,
@RequestParam(value = "limit", required = false) Integer limit) {
- return formService.answers(schId, formUuid, after, updatedSince, limit);
+ return formService.answers(caller(request), schId, formUuid, after, updatedSince, limit);
+ }
+
+ /**
+ * 表单目录(v3 §3):给消费方的选择器用,返回 {@code {items,total}}。
+ *
+ * 服务端过滤链=已发布 ∩ 未软删 ∩ 本校或平台级 ∩ 调用方可读,过滤完才分页
+ * (先分页后过滤会让 {@code total} 与页数随授权抖动)。{@code pageSize} 上限 100。
+ */
+ @GetMapping("/forms")
+ public CatalogPage forms(HttpServletRequest request,
+ @RequestParam("schId") Long schId,
+ @RequestParam(value = "appCode", required = false) String appCode,
+ @RequestParam(value = "keyword", required = false) String keyword,
+ @RequestParam(value = "page", required = false) Integer page,
+ @RequestParam(value = "pageSize", required = false) Integer pageSize) {
+ return formService.catalog(caller(request), schId, appCode, keyword, page, pageSize);
+ }
+
+ /** 已验签的消费应用身份;属性缺失=验签墙没生效,解析器按未授权拒(fail-closed) */
+ private ConsumerPrincipal caller(HttpServletRequest request) {
+ Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
+ return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
}
}
diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/SubmissionContextController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/SubmissionContextController.java
new file mode 100644
index 0000000..bf40340
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/oapi/SubmissionContextController.java
@@ -0,0 +1,56 @@
+package com.yunhai.formcenter.controller.oapi;
+
+import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextIssueRequest;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextIssueView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextReceiptView;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
+import com.yunhai.formcenter.service.SubmissionContextService;
+import com.yunhai.platform.appauth.AppAuthFilter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 可信提交上下文端点(answer-delivery-v2 §6,TASK_2 A.2/A.4)。
+ *
+ * 独立控制器、独立 DTO:任务书明令不改 TASK_1 的 {@code OapiFormController/OapiModels},
+ * 上下文与收据这条链全部落在这里({@link com.yunhai.formcenter.model.AnswerDeliveryModels})。
+ * 路径仍在 {@code /oapi/*} 之下,由既有 {@code RequireAppSignFilter} 强制应用签名,
+ * 调用方身份只取验签后的 {@code yh.app.auth}——请求体里没有任何"自报应用"的字段。
+ *
+ *
+ * POST /oapi/form/submission-context → 签发/复用上下文(同意图不新建第二条业务意图)
+ * GET /oapi/form/submission-context/{intentId}/receipt → 收据(只向所属消费应用返回)
+ *
+ */
+@RestController
+@RequestMapping("/oapi/form/submission-context")
+@RequiredArgsConstructor
+public class SubmissionContextController {
+
+ private final SubmissionContextService contextService;
+ private final ConsumerPrincipalResolver callerResolver;
+
+ @PostMapping
+ public ContextIssueView issue(HttpServletRequest request, @RequestBody ContextIssueRequest body) {
+ return contextService.issue(caller(request), body);
+ }
+
+ @GetMapping("/{intentId}/receipt")
+ public ContextReceiptView receipt(HttpServletRequest request, @PathVariable("intentId") String intentId) {
+ return contextService.receipt(caller(request), intentId);
+ }
+
+ /** 已验签的消费应用身份;属性缺失=验签墙没生效,解析器按未授权拒(fail-closed,同 v3 §1) */
+ private ConsumerPrincipal caller(HttpServletRequest request) {
+ Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
+ return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java
index 3789560..0496d17 100644
--- a/src/main/java/com/yunhai/formcenter/entity/Answer.java
+++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java
@@ -139,4 +139,22 @@ public class Answer extends BaseEntity {
/** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
+
+ /**
+ * 对外快照修订号(v2 投递契约 §2,TASK_2 B.1):单调递增,与 schema 的
+ * {@code version}(钉版坐标,{@code updatable=false})是两回事——前者说"这份数据又被改过一次",
+ * 后者说"它是按哪一版结构填的"。
+ *
+ * 凡是会改变对外答卷快照的路径都在同一事务里 +1 并写不可变 outbox:
+ * 创建、本人/管理员值编辑、改业务状态(含批量)、软删(含批量)。刻意不把
+ * {@code answer_index} 重建、留痕行这类"内部副作用"算作 revision 变更点之外的口径——
+ * 判据只有一条:消费方看到的字节变了吗。
+ *
+ * 并发控制=行级 CAS({@code AnswerRepository#compareAndSetRevision},UPDATE 自身拿行锁,
+ * 抢不到号即 0 行→回滚)+ outbox 的 {@code uk_delivery_revision} 唯一键兜底。
+ * 没上 JPA {@code @Version}:那会让存量分离实体 {@code save} 路径(并发 IT 与批量导入)
+ * 突然开始抛 OptimisticLockException,属于跨批次的行为变更,不在本任务授权范围内。
+ */
+ @Column(name = "answer_revision", nullable = false)
+ private Long answerRevision = 0L;
}
diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerDelivery.java b/src/main/java/com/yunhai/formcenter/entity/AnswerDelivery.java
new file mode 100644
index 0000000..3ce75fe
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/AnswerDelivery.java
@@ -0,0 +1,148 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 答卷快照投递行(outbox,answer-delivery-v2 §3,TASK_2 B.1/B.3/B.4)。
+ *
+ * 它是持久性载体,不是发送日志:行必须在答卷同一事务里落库(旧
+ * {@code FcEventPublisher} 的 afterCommit 只负责"发",进程在提交与发送之间被杀就永久丢事件——
+ * 契约 v2 那时可以用"拉取即事实源"兜底,v2 快照投递不行,因为它要驱动对端的办件)。
+ * afterCommit 从此只剩一个作用:唤醒扫描。
+ *
+ * 唯一键 {@code uk_delivery_revision} 是"同一 revision 不能对应两份不同 hash"的库级形态:
+ * 两个并发写事务都读到 revision=N-1、各自算出 N 时,只有一个能插入成功,另一个整事务回滚
+ * (答卷与 outbox 同行消失),不存在"两条同版不同内容的投递行"。快照内容因此不可重建历史版本,
+ * 每行自带 {@code snapshot_json}——{@code intent} 关联块只对单个 consumer 可见,
+ * 各行的载荷本来就不同,不做跨行共享。
+ *
+ * {@code state} 只描述投递,不描述业务:{@code BROKER_ACCEPTED} 只代表总线收下
+ * (B.4 明令不得写成消费成功),消费方是否真的落库要看 {@code consumer_applied_at} /
+ * {@code consumer_outcome},那两列只由签名回执端点写。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "answer_delivery",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_delivery_event", columnNames = "event_id"),
+ @UniqueConstraint(name = "uk_delivery_revision",
+ columnNames = {"answer_uuid", "answer_revision", "consumer_app_key", "consumer_school_id"})
+ },
+ indexes = {
+ @Index(name = "idx_delivery_scan", columnList = "state, next_retry_at"),
+ @Index(name = "idx_delivery_consumer", columnList = "consumer_app_key, consumer_school_id, id"),
+ @Index(name = "idx_delivery_answer", columnList = "answer_uuid, answer_revision")})
+public class AnswerDelivery extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ // ===== 投递状态机(state 列)=====
+ /** 已落库待发送(含 broker 不可用时的退避等待) */
+ public static final String STATE_PENDING = "PENDING";
+ /** 总线 confirm 收下(≠ 消费方应用成功) */
+ public static final String STATE_BROKER_ACCEPTED = "BROKER_ACCEPTED";
+ /** 发送异常/mandatory 不可路由,退避后仍会重试 */
+ public static final String STATE_FAILED = "FAILED";
+ /** 有界重试耗尽,留给人工/对账处置(可查,不再自动重投) */
+ public static final String STATE_DEAD = "DEAD";
+ /** 发布前复核发现 grant 已失效/进位:不重放旧授权(v2 §5 撤权边界) */
+ public static final String STATE_GRANT_REVOKED = "GRANT_REVOKED";
+
+ /** 消费方结果:本地已落库 */
+ public static final String OUTCOME_APPLIED = "APPLIED";
+ /** 消费方结果:按 revision 判定无需应用(低版本或同版同 hash 重放) */
+ public static final String OUTCOME_IGNORED = "IGNORED";
+ /** 消费方结果:同 revision 异 hash,冲突待核验(消费端不覆盖) */
+ public static final String OUTCOME_CONFLICT = "CONFLICT";
+
+ /** 信封事件 id(uuid;broker 侧相关键与消费端幂等键都用它) */
+ @Column(name = "event_id", nullable = false, length = 36, updatable = false)
+ private String eventId;
+
+ @Column(name = "protocol_version", nullable = false, length = 32, updatable = false)
+ private String protocolVersion;
+
+ /** created / updated / tombstoned */
+ @Column(name = "event_type", nullable = false, length = 16, updatable = false)
+ private String eventType;
+
+ /** 发布方应用身份(服务端配置,不是消费方申报) */
+ @Column(name = "source_app_key", length = 64)
+ private String sourceAppKey;
+
+ /** 投递目标:显式消费应用 appKey(不是资源 appCode,B.3) */
+ @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
+ private String consumerAppKey;
+
+ @Column(name = "consumer_school_id", nullable = false, updatable = false)
+ private Long consumerSchoolId;
+
+ /** 入队时刻的授权纪元(发送前还要再核一次,B.3) */
+ @Column(name = "grant_version", nullable = false)
+ private Integer grantVersion;
+
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ @Column(name = "form_version", nullable = false, updatable = false)
+ private Integer formVersion;
+
+ @Column(name = "answer_uuid", nullable = false, length = 32, updatable = false)
+ private String answerUuid;
+
+ /** 单调快照修订号(与 answer.answer_revision 同值) */
+ @Column(name = "answer_revision", nullable = false, updatable = false)
+ private Long answerRevision;
+
+ /** 墓碑标记(软删行的快照照常投递,靠它表达"删除") */
+ @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint", updatable = false)
+ private Integer isDeleted = 0;
+
+ /** {@code fc.canon.v1} 下快照(剔除 intent)的 SHA-256 */
+ @Column(name = "content_hash", nullable = false, length = 64, updatable = false)
+ private String contentHash;
+
+ /** 业务发生时刻(快照内已含 create/updateTime,这一列是信封 occurredAt 的落库形态) */
+ @Column(name = "occurred_at", nullable = false)
+ private LocalDateTime occurredAt;
+
+ /** 不可变载荷:该 consumer 看到的快照 JSON(含 intent 关联块) */
+ @Column(name = "snapshot_json", nullable = false, columnDefinition = "longtext")
+ private String snapshotJson;
+
+ @Column(name = "state", nullable = false, length = 16)
+ private String state = STATE_PENDING;
+
+ @Column(name = "retry_count", nullable = false)
+ private Integer retryCount = 0;
+
+ /** 退避到点才再扫(broker 故障不许热循环) */
+ @Column(name = "next_retry_at")
+ private LocalDateTime nextRetryAt;
+
+ @Column(name = "broker_accepted_at")
+ private LocalDateTime brokerAcceptedAt;
+
+ @Column(name = "consumer_applied_at")
+ private LocalDateTime consumerAppliedAt;
+
+ /** APPLIED / IGNORED / CONFLICT(只有签名回执端点写) */
+ @Column(name = "consumer_outcome", length = 16)
+ private String consumerOutcome;
+
+ /** 最近一次授权重投的操作幂等键({@code replayAuthorized} 写;同键重复调用不再刷新退避) */
+ @Column(name = "last_replay_operation_id", length = 64)
+ private String lastReplayOperationId;
+
+ @Column(name = "last_error", length = 500)
+ private String lastError;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileManifestRow.java b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileManifestRow.java
new file mode 100644
index 0000000..91af75e
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileManifestRow.java
@@ -0,0 +1,72 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 任务 manifest 行(TASK_3,answer-reconcile-v1 §3)。
+ *
+ * 本任务里唯一充当"不可变清单"的东西:在一致读事务内物化
+ * {@code (answerUuid, revision, contentHash, isDeleted)} 四元摘要(含墓碑),
+ * 之后所有分页只读这张表,不再回 {@code answer} 表。清单因此不会被物化之后发生的写入改动,
+ * "这一页空了"与"这个集合到底了"是两件事——后者只能由 {@code manifestComplete} 表达。
+ *
+ * 键序按 {@code answer_uuid} 升序(32 位小写十六进制),与消费端本地 keyset 扫描同序,
+ * 两侧能用同一个游标语义锁步推进。哈希取 {@code AnswerSnapshotService} 的
+ * {@code fc.canon.v1} 口径,与投递行同一条规范化实现——本类不复制任何规范化逻辑。
+ *
+ * {@code formVersion} 取物化时该答卷钉住的 schema 版本(不是表单当前版本,也不从旧清单猜):
+ * 修复信封的 {@code formVersion} 必须由中心已核验的坐标给出,这一列就是那个坐标的留痕。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "answer_reconcile_manifest_row",
+ uniqueConstraints = @UniqueConstraint(name = "uk_manifest_row",
+ columnNames = {"task_id", "answer_uuid"}),
+ indexes = {
+ @Index(name = "idx_manifest_page", columnList = "task_id, answer_uuid"),
+ @Index(name = "idx_manifest_seq", columnList = "task_id, seq_no")})
+public class AnswerReconcileManifestRow extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 逻辑引用 answer_reconcile_task.id(不建物理外键:任务行是审计资产,清单要能在其后继续存在) */
+ @Column(name = "task_id", nullable = false, updatable = false)
+ private Long taskId;
+
+ @Column(name = "task_uuid", nullable = false, length = 36, updatable = false)
+ private String taskUuid;
+
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ /** 物化时核验过的发布版本(修复信封坐标) */
+ @Column(name = "form_version", nullable = false, updatable = false)
+ private Integer formVersion;
+
+ @Column(name = "answer_uuid", nullable = false, length = 32, updatable = false)
+ private String answerUuid;
+
+ /** 物化时刻的对外修订号(不是库内 id、不是页码) */
+ @Column(name = "revision", nullable = false, updatable = false)
+ private Long revision;
+
+ /** fc.canon.v1 摘要(64 位十六进制) */
+ @Column(name = "content_hash", nullable = false, length = 64, updatable = false)
+ private String contentHash;
+
+ /** 墓碑也进清单:缺席与"存在但已删"是两个不同结论 */
+ @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint", updatable = false)
+ private Integer isDeleted;
+
+ /** 物化写入序号(审计用;分页游标是 answer_uuid,不是它) */
+ @Column(name = "seq_no", nullable = false, updatable = false)
+ private Integer seqNo;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileResult.java b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileResult.java
new file mode 100644
index 0000000..9429e42
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileResult.java
@@ -0,0 +1,122 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 对账结果回报(TASK_3,answer-reconcile-v1 §5):消费端一次执行的可追踪事实。
+ *
+ * 唯一键 {@code (task_id, attempt)} 是幂等锚点:同一 attempt 重复上报直接吸收,
+ * 比已记录 attempt 更旧的回报只留档、不推进任务状态("不让旧 attempt 回报倒退新 attempt")。
+ * 一行=一个 attempt,重派产生的新 attempt 是另一行,历史不被覆盖。
+ *
+ * 计数都是有上限的摘要:明细只留在 {@code errors_json}(截断到此列长度),
+ * 答卷的 values 一律不进这里,也不进日志(§8 敏感数据纪律)。
+ *
+ * 状态语义与任务终态一致:{@code COMPLETED}=两侧集合与摘要在完整扫描下无未决差异,
+ * {@code PARTIAL}=扫描或修复有未决项(差异仍在,但任务本身跑完了),
+ * {@code FAILED}/{@code CANCELLED}=执行中断。两者不能混为一谈。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "answer_reconcile_result",
+ uniqueConstraints = @UniqueConstraint(name = "uk_result_attempt",
+ columnNames = {"task_id", "attempt"}),
+ indexes = {
+ @Index(name = "idx_result_task", columnList = "task_id, attempt"),
+ @Index(name = "idx_result_consumer", columnList = "consumer_app_key, consumer_school_id, id")})
+public class AnswerReconcileResult extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ @Column(name = "task_id", nullable = false, updatable = false)
+ private Long taskId;
+
+ @Column(name = "task_uuid", nullable = false, length = 36, updatable = false)
+ private String taskUuid;
+
+ @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
+ private String consumerAppKey;
+
+ @Column(name = "consumer_school_id", nullable = false, updatable = false)
+ private Long consumerSchoolId;
+
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ /** 本次回报对应的派发尝试(指令里的 attempt;0=首派) */
+ @Column(name = "attempt", nullable = false, updatable = false)
+ private Integer attempt;
+
+ /** COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报) */
+ @Column(name = "state", nullable = false, length = 16, updatable = false)
+ private String state;
+
+ /** 消费端本地扫描是否整轮完成(多余行判定的前提之一) */
+ @Column(name = "local_scan_complete", nullable = false, columnDefinition = "tinyint", updatable = false)
+ private Integer localScanComplete;
+
+ /** 消费端实际读到的中心清单结束标记(必须与中心一致,否则本轮结论不成立) */
+ @Column(name = "manifest_complete", nullable = false, columnDefinition = "tinyint", updatable = false)
+ private Integer manifestComplete;
+
+ @Column(name = "scanned_rows", nullable = false, updatable = false)
+ private Integer scannedRows;
+
+ @Column(name = "pages_processed", nullable = false, updatable = false)
+ private Integer pagesProcessed;
+
+ /** 缺失修复(本地无行,按中心当前值建未关联投影) */
+ @Column(name = "missing_repaired", nullable = false, updatable = false)
+ private Integer missingRepaired;
+
+ /** 陈旧修复(本地 revision 更低) */
+ @Column(name = "stale_repaired", nullable = false, updatable = false)
+ private Integer staleRepaired;
+
+ /** 同 revision 异 hash:记录后按中心当前值修复 */
+ @Column(name = "hash_conflicts", nullable = false, updatable = false)
+ private Integer hashConflicts;
+
+ /** 墓碑修复(同一权威修复原语,保留本地业务扩展) */
+ @Column(name = "tombstones_repaired", nullable = false, updatable = false)
+ private Integer tombstonesRepaired;
+
+ /** 本地 revision 更高:只复核、不降级覆盖 */
+ @Column(name = "local_ahead", nullable = false, updatable = false)
+ private Integer localAhead;
+
+ /** 授权核验不可达/撤权/纪元不等而暂停的条目 */
+ @Column(name = "deferred", nullable = false, updatable = false)
+ private Integer deferred;
+
+ /** 只报告的多余行(含期间新增、createTime 未知、范围不明) */
+ @Column(name = "extra_reported", nullable = false, updatable = false)
+ private Integer extraReported;
+
+ /** 经 FC 签名缺席证明实际退役的行数(未过证明即不增) */
+ @Column(name = "extra_retired", nullable = false, updatable = false)
+ private Integer extraRetired;
+
+ /** 错误条目总数(可能被截断,明细数以 errors_json 里为准) */
+ @Column(name = "error_count", nullable = false, updatable = false)
+ private Integer errorCount;
+
+ @Column(name = "errors_json", nullable = false, columnDefinition = "mediumtext", updatable = false)
+ private String errorsJson;
+
+ /** 消费端自报的执行起点(断线重启续跑时用于核对检查点) */
+ @Column(name = "resumed", nullable = false, columnDefinition = "tinyint", updatable = false)
+ private Integer resumed;
+
+ @Column(name = "reported_at", nullable = false, updatable = false)
+ private LocalDateTime reportedAt;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileTask.java b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileTask.java
new file mode 100644
index 0000000..a8f2eea
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileTask.java
@@ -0,0 +1,184 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 差异对账任务(TASK_3,answer-reconcile-v1 §2)。
+ *
+ * 它是任务,不是投递行、不是答卷、也不是 {@code answer_op_log}:一行=一个
+ * {@code (消费应用, 消费租户域, 表单)} 坐标上的一次对账企图。投递行({@link AnswerDelivery})
+ * 描述"这份快照有没有发出去",本行描述"这两边的集合差在哪、修复回报过没有",两者不能互相代替。
+ *
+ * 任务坐标只到表单,不再宽到"全部表单":全量=按生效订阅逐张表 fission 成多行任务
+ * ({@code idempotencyKey} 里的日期+订阅键保证同一天同一订阅只建一次)。这样每一行的
+ * manifest 都能用单一 {@code answerUuid} 游标分页,与消费端本地 keyset 扫描同序,
+ * 两侧可以锁步比较——把"跨表单复合游标"这种一定会写错的东西从设计里删掉。
+ *
+ * 派发纪律:{@code leaseUntil} 是本应用持有派发权的期限,超期由扫描按 {@code attempt+1}
+ * 有限退避重派;{@code dispatchedAt} 只说明"对端 2xx 收下了指令",
+ * 绝不等于对端做完——终态只能由 {@link AnswerReconcileResult} 的回报推进。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "answer_reconcile_task",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_reconcile_task_uuid", columnNames = "task_uuid"),
+ @UniqueConstraint(name = "uk_reconcile_task_idem", columnNames = "idempotency_key")
+ },
+ indexes = {
+ @Index(name = "idx_reconcile_dispatch", columnList = "state, next_dispatch_at"),
+ @Index(name = "idx_reconcile_consumer", columnList = "consumer_app_key, consumer_school_id, id"),
+ @Index(name = "idx_reconcile_coord", columnList = "consumer_app_key, consumer_school_id, form_uuid")})
+public class AnswerReconcileTask extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ // ===== 任务状态(验收状态闭集,§4)=====
+ public static final String STATE_QUEUED = "QUEUED";
+ public static final String STATE_DISPATCHED = "DISPATCHED";
+ public static final String STATE_RUNNING = "RUNNING";
+ public static final String STATE_COMPLETED = "COMPLETED";
+ public static final String STATE_PARTIAL = "PARTIAL";
+ public static final String STATE_FAILED = "FAILED";
+ public static final String STATE_CANCELLED = "CANCELLED";
+
+ /** 终态:不再被派发扫描领取,也不接受进度回退 */
+ public static boolean isTerminal(String state) {
+ return STATE_COMPLETED.equals(state) || STATE_FAILED.equals(state) || STATE_CANCELLED.equals(state);
+ }
+
+ /** 活跃态:同一坐标上只允许一个(单并发),且这些状态要受租约与取消约束 */
+ public static boolean isActive(String state) {
+ return STATE_QUEUED.equals(state) || STATE_DISPATCHED.equals(state) || STATE_RUNNING.equals(state);
+ }
+
+ // ===== 触发方式 =====
+ public static final String TRIGGER_SCHEDULE = "SCHEDULE";
+ public static final String TRIGGER_MANUAL = "MANUAL";
+
+ // ===== 稳定错误码(每条错误都要能定位 consumer/task/range/answer,§8)=====
+ public static final String ERR_GRANT_REVOKED = "GRANT_REVOKED";
+ public static final String ERR_MANIFEST_INCOMPLETE = "MANIFEST_INCOMPLETE";
+ public static final String ERR_RESOURCE_LIMIT = "RESOURCE_LIMIT";
+ public static final String ERR_TARGET_NOT_REGISTERED = "TARGET_NOT_REGISTERED";
+ public static final String ERR_DISPATCH_FAILED = "DISPATCH_FAILED";
+ public static final String ERR_LEASE_EXPIRED = "LEASE_EXPIRED";
+ public static final String ERR_CANCELLED = "CANCELLED";
+ public static final String ERR_TASK_NOT_FOUND = "TASK_NOT_FOUND";
+ public static final String ERR_SCOPE_MISMATCH = "SCOPE_MISMATCH";
+
+ /** 任务对外身份(服务端 UUID,≤36 字符;URL 与指令里都用它,不用库内 id) */
+ @Column(name = "task_uuid", nullable = false, length = 36, updatable = false)
+ private String taskUuid;
+
+ @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
+ private String consumerAppKey;
+
+ /** 消费租户域:与投递行同口径,也就是答卷的归属校(§1 坐标定义) */
+ @Column(name = "consumer_school_id", nullable = false, updatable = false)
+ private Long consumerSchoolId;
+
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ /** 物化时核验过的发布版本;缺席答复与修复信封的 formVersion 都取这一个值 */
+ @Column(name = "form_version", nullable = false)
+ private Integer formVersion;
+
+ /** SCHEDULE / MANUAL(列名避开 MySQL 保留字 TRIGGER) */
+ @Column(name = "trigger_type", nullable = false, length = 16, updatable = false)
+ private String triggerType;
+
+ @Column(name = "operator", length = 64, updatable = false)
+ private String operator;
+
+ /** 日期 + 订阅 + 范围唯一键:调度重复跑不出第二行任务 */
+ @Column(name = "idempotency_key", nullable = false, length = 128, updatable = false)
+ private String idempotencyKey;
+
+ @Column(name = "state", nullable = false, length = 16)
+ private String state = STATE_QUEUED;
+
+ /** manifest 物化状态:派发只在 COMPLETE 后进行 */
+ @Column(name = "manifest_state", nullable = false, length = 16)
+ private String manifestState = MANIFEST_PENDING;
+
+ public static final String MANIFEST_PENDING = "PENDING";
+ public static final String MANIFEST_MATERIALIZING = "MATERIALIZING";
+ public static final String MANIFEST_COMPLETE = "COMPLETE";
+ public static final String MANIFEST_FAILED = "FAILED";
+
+ /** 结束标记:只有它为 1,"清单里没有"才可能是权威缺席 */
+ @Column(name = "manifest_complete", nullable = false, columnDefinition = "tinyint")
+ private Integer manifestComplete = 0;
+
+ @Column(name = "total_rows", nullable = false)
+ private Integer totalRows = 0;
+
+ @Column(name = "total_pages", nullable = false)
+ private Integer totalPages = 0;
+
+ @Column(name = "materialized_rows", nullable = false)
+ private Integer materializedRows = 0;
+
+ @Column(name = "materialized_pages", nullable = false)
+ private Integer materializedPages = 0;
+
+ /** 物化检查点:已写到的最后一个 answerUuid(进程重启从这里续) */
+ @Column(name = "checkpoint_cursor", length = 32)
+ private String checkpointCursor;
+
+ /**
+ * 任务起点:一致读事务的开始时刻。消费端据此把"本地 createTime 晚于此刻"的行
+ * 判为期间新增——这类行永远不许被当多余行退役。
+ */
+ @Column(name = "window_start", nullable = false, updatable = false)
+ private LocalDateTime windowStart;
+
+ /** manifest 物化完成时刻(一致读的终点,给运维看窗口长度) */
+ @Column(name = "window_end")
+ private LocalDateTime windowEnd;
+
+ /** 物化时刻的授权纪元;每轮读取再核一次,进位即视为撤权 */
+ @Column(name = "grant_version", nullable = false)
+ private Integer grantVersion = 1;
+
+ /** 派发尝试次数:首次为 0,租约超期或派发失败后 +1 */
+ @Column(name = "attempt", nullable = false)
+ private Integer attempt = 0;
+
+ @Column(name = "lease_until")
+ private LocalDateTime leaseUntil;
+
+ @Column(name = "next_dispatch_at")
+ private LocalDateTime nextDispatchAt;
+
+ @Column(name = "dispatched_at")
+ private LocalDateTime dispatchedAt;
+
+ @Column(name = "finished_at")
+ private LocalDateTime finishedAt;
+
+ /** 取消请求:管理面置 1 后,后续清单/快照读取一律以取消口径答复,消费端据此停止剩余修复 */
+ @Column(name = "cancel_requested", nullable = false, columnDefinition = "tinyint")
+ private Integer cancelRequested = 0;
+
+ @Column(name = "error_code", length = 32)
+ private String errorCode;
+
+ @Column(name = "error_detail", length = 500)
+ private String errorDetail;
+
+ public boolean isCancelled() {
+ return Integer.valueOf(1).equals(cancelRequested) || STATE_CANCELLED.equals(state);
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormConsumerGrant.java b/src/main/java/com/yunhai/formcenter/entity/FormConsumerGrant.java
new file mode 100644
index 0000000..ed27e80
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/FormConsumerGrant.java
@@ -0,0 +1,65 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 消费应用对某张表单的显式授权(消费应用授权 v3 §2,TASK_1)。
+ *
+ * 三元唯一:{@code (form_uuid, consumer_school_id, consumer_app_key)}——授权是
+ * "这张表允许这个租户的这个应用读/订阅",租户与应用缺一不可。只按 appKey 授权会丢掉租户维度,
+ * 而"读答卷仍以该校行集限定"(v3 §2)正是靠 {@code consumer_school_id} 落型的:
+ * 平台级表单({@code form.sch_id=0})对某校授予后,答卷行集依旧按那一校限定。
+ *
+ * 默认拒绝:读与订阅是分开的两把开关,都没有隐式全集;owner 应用天然有读权,
+ * 但订阅同样要显式开一行(读/写分权是这一版的红线)。撤销不删行,改 {@code enabled=0}
+ * 并让 {@code grantVersion} 进位——留着行才答得出"什么时候撤的权"。
+ *
+ * {@code grantVersion} 是给 TASK_3 事件面用的:订阅者必须带回版本号,消费方才能识别
+ * "这条推送用的授权已经作废",不允许那边再从库里自己拼一套授权判定。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "form_consumer_grant",
+ uniqueConstraints = @UniqueConstraint(name = "uk_grant",
+ columnNames = {"form_uuid", "consumer_school_id", "consumer_app_key"}),
+ indexes = @Index(name = "idx_grant_consumer", columnList = "consumer_app_key, consumer_school_id, enabled"))
+public class FormConsumerGrant extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 被授权的表单(全局 uuid) */
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ /** 消费租户:授权生效的学校域,也是读答卷时的行集限定域 */
+ @Column(name = "consumer_school_id", nullable = false)
+ private Long consumerSchoolId;
+
+ /** 消费应用的平台身份(appKey,非 app_code) */
+ @Column(name = "consumer_app_key", nullable = false, length = 64)
+ private String consumerAppKey;
+
+ /** 读授权(目录/schema/answer/answers):1=允许 */
+ @Column(name = "read_allowed", nullable = false, columnDefinition = "tinyint")
+ private Integer readAllowed = 0;
+
+ /** 订阅授权(答卷变更事件投递):1=允许 */
+ @Column(name = "subscribe_allowed", nullable = false, columnDefinition = "tinyint")
+ private Integer subscribeAllowed = 0;
+
+ /** 生效开关:0=已撤销(保留行与 grant_version 供审计,撤销后后续读取/发布检查即失败) */
+ @Column(name = "enabled", nullable = false, columnDefinition = "tinyint")
+ private Integer enabled = 1;
+
+ /** 授权版本号:每次撤销/变更进位,订阅投递与消费方幂等判定都带它 */
+ @Column(name = "grant_version", nullable = false)
+ private Integer grantVersion = 1;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormOwnership.java b/src/main/java/com/yunhai/formcenter/entity/FormOwnership.java
new file mode 100644
index 0000000..f1d4a00
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/FormOwnership.java
@@ -0,0 +1,49 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 表单的资源拥有者绑定(消费应用授权 v3 §2,TASK_1)。
+ *
+ * 它只回答一件事:这张表单(按全局 uuid)是哪个平台应用(appKey)的资产。
+ * 拥有者与消费租户是两件事——{@code owner_app_key} 是资产归属,"别的应用能不能读它"一律走
+ * {@link FormConsumerGrant},所以 owner 与 appCode 之间没有任何推导关系:
+ * 客户端自报的 {@code appCode} 只能筛资源,永远不授予拥有权(v3 §1)。
+ *
+ * owner 由谁写:只有管理面(校管理员/超管,见 {@code FormAccessController})能设置,
+ * 且写入前用平台密钥注册表核验过 appKey 确实在 UserBase 注册存在;签名应用不能自报 owner。
+ * 缺归属行=没有拥有者,除显式 grant 外一律拒(fail-closed,默认拒绝)。
+ *
+ * {@code form_school_id} 存的是绑定时表单自己的 {@code sch_id}(0=平台级),
+ * 作为"这条绑定属于哪个资源域"的留痕:管理面的域判定用它复核,防止日后表单换校
+ * 让一条跨校绑定看起来仍然合法。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "form_ownership",
+ uniqueConstraints = @UniqueConstraint(name = "uk_ownership_form", columnNames = "form_uuid"),
+ indexes = @Index(name = "idx_ownership_owner", columnList = "owner_app_key, form_school_id"))
+public class FormOwnership extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 资源坐标:表单全局 uuid(契约 §1:不可枚举、跨版本不变);一表一绑定 */
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ /** 绑定时表单的归属校;0=平台级表单 */
+ @Column(name = "form_school_id", nullable = false)
+ private Long formSchoolId;
+
+ /** 拥有者应用的平台身份(UserBase 注册分配的 appKey,不是 app_code) */
+ @Column(name = "owner_app_key", nullable = false, length = 64)
+ private String ownerAppKey;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/SubmissionContext.java b/src/main/java/com/yunhai/formcenter/entity/SubmissionContext.java
new file mode 100644
index 0000000..9c966d8
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/SubmissionContext.java
@@ -0,0 +1,101 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 可信提交上下文(answer-delivery-v2 §6,TASK_2 A.2/A.3)。
+ *
+ * 它解决的是"答卷到底是不是这次业务意图产生的"这一个事实:办事大厅先建意图、
+ * 再让被嵌页填报,中心必须能把答卷钉回那次意图,而不是相信浏览器带来的 bizId/answerId。
+ * 三条形状因此都是必需的:
+ *
+ * - 只存 token 摘要:明文 {@code contextToken} 只在签发响应里出现一次;
+ * 库被读也不能拿它去兑换别人的填报(防"拖库即冒充");
+ * - 唯一键 (consumer_app_key, intent_id):同意图重试返回同一有效上下文,
+ * 结构上不允许出现"第二个业务意图"(A.2);
+ * - 唯一键 answer_uuid:一份答卷只能原子关联到一个意图,
+ * "同 token 同幂等键返回原答卷、不同内容重用拒绝"在库级就有兜底(A.3)。
+ *
+ *
+ * {@code embed_origin}/{@code callback_url} 由服务端配置/注册信息填写(签发时落定),
+ * 请求体里没有这两个字段——浏览器给的任何 URL 都不采信(A.2 末句)。
+ * {@code submitter_id} 是消费方服务端断言的填报人,中心在 redeem 时与 CAS 会话逐字复核,
+ * 对不上就是 {@code FORBIDDEN}(token 错人)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "submission_context",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_ctx_token_digest", columnNames = "token_digest"),
+ @UniqueConstraint(name = "uk_ctx_intent", columnNames = {"consumer_app_key", "intent_id"}),
+ @UniqueConstraint(name = "uk_ctx_answer", columnNames = "answer_uuid")
+ },
+ indexes = {@Index(name = "idx_ctx_form", columnList = "form_uuid, form_version")})
+public class SubmissionContext extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 消费应用 appKey(验签后的 yh.app.auth,不是请求体里的 appCode) */
+ @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
+ private String consumerAppKey;
+
+ /** 消费租户域(授权与行集限定都用它,v3 §2 的两个维度之一) */
+ @Column(name = "consumer_school_id", nullable = false, updatable = false)
+ private Long consumerSchoolId;
+
+ /** 消费方业务意图键(服务端生成,≤64) */
+ @Column(name = "intent_id", nullable = false, length = 64, updatable = false)
+ private String intentId;
+
+ /** 钉定的表单坐标(成对,签发时即按 v3 §6 校验过版本行存在) */
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ @Column(name = "form_version", nullable = false, updatable = false)
+ private Integer formVersion;
+
+ /** 解析出来的资源坐标(hosted 页提交时按它寻址,不再走 code 解析) */
+ @Column(name = "app_code", nullable = false, length = 64, updatable = false)
+ private String appCode;
+
+ @Column(name = "code", nullable = false, length = 64, updatable = false)
+ private String code;
+
+ /** 消费方断言的填报人(hosted 首批=登录本人;redeem 时与会话逐字比对) */
+ @Column(name = "submitter_id", nullable = false, updatable = false)
+ private Integer submitterId;
+
+ /** contextToken 的 SHA-256(明文不入库) */
+ @Column(name = "token_digest", nullable = false, length = 64, updatable = false)
+ private String tokenDigest;
+
+ /** 签发时锁定的可嵌入父页面 origin(精确 scheme+host+port) */
+ @Column(name = "embed_origin", nullable = false, length = 200)
+ private String embedOrigin;
+
+ /** 签发时锁定的消费方确认入口(服务端配置,非浏览器传入) */
+ @Column(name = "callback_url", nullable = false, length = 500)
+ private String callbackUrl;
+
+ @Column(name = "expires_at", nullable = false, updatable = false)
+ private LocalDateTime expiresAt;
+
+ /** 答卷原子关联(提交事务内写;NULL=尚未填报。uk_ctx_answer 允许多个 NULL) */
+ @Column(name = "answer_uuid", length = 32)
+ private String answerUuid;
+
+ @Column(name = "linked_revision")
+ private Long linkedRevision;
+
+ @Column(name = "linked_at")
+ private LocalDateTime linkedAt;
+}
diff --git a/src/main/java/com/yunhai/formcenter/model/AnswerDeliveryModels.java b/src/main/java/com/yunhai/formcenter/model/AnswerDeliveryModels.java
new file mode 100644
index 0000000..2ff8c83
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/AnswerDeliveryModels.java
@@ -0,0 +1,301 @@
+package com.yunhai.formcenter.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 答卷投递 v2 的出入参(answer-delivery-v2 契约,TASK_2)。
+ *
+ * 三条形状纪律:
+ *
+ * - 信封字段闭集:{@link SnapshotEnvelope} 逐字段标 {@link JsonProperty}——
+ * 契约里写的是 camelCase,靠命名策略碰巧对上是脆弱的,多一个字段就该被当成违约(同 v1 §6.2 的
+ * {@code FcEventPublisherTest} 立场);
+ * - 身份不进消费方请求:{@link ContextIssueRequest} 里没有 appKey/consumer 身份位,
+ * 调用方身份只取验签后的 {@code yh.app.auth}(v3 §1,TASK_1 已定的口径);
+ * 浏览器侧更不会出现在这条链上(这是服务端到服务端的签名调用);
+ * - 回执与投递状态分开:{@code brokerAccepted}(总线收下)与
+ * {@code consumerApplied}(消费方业务落库并回执)是两个时刻、两组列,
+ * 谁也不能替谁作证(B.4/B.6)。
+ *
+ */
+public final class AnswerDeliveryModels {
+
+ /** v2 协议号(同时锁定 {@code AnswerCanonicalizer.fc.canon.v1} 的哈希口径) */
+ public static final String PROTOCOL_VERSION = "fc.answer.snapshot.v2";
+
+ /** v2 专用 exchange(与 v1 广播 exchange 物理分开,见配置与部署契约) */
+ public static final String EXCHANGE = "fc.answer.delivery.v2";
+
+ /** 事件类型闭集:值/状态变更都归 {@code updated},墓碑单列 */
+ public static final String EVENT_CREATED = "created";
+ public static final String EVENT_UPDATED = "updated";
+ public static final String EVENT_TOMBSTONED = "tombstoned";
+
+ private AnswerDeliveryModels() {
+ }
+
+ /** 消费端扩展字段的类型标记(消费方本地列,永不参与快照与哈希) */
+ @Getter
+ @Setter
+ public static class Snapshot {
+ private Long schId;
+ private String appCode;
+ private String code;
+ private Integer submitterId;
+ private Integer subjectId;
+ private String status;
+ private JsonNode values;
+ private String answerNo;
+ /** ISO-8601 本地日期时间({@code yyyy-MM-ddTHH:mm:ss}),固定形态、不带时区偏移 */
+ private String createTime;
+ private String updateTime;
+ /** 仅对本 consumer 可见的意图关联(不参与 contentHash,见 fc.canon.v1 规则⑥) */
+ private JsonNode intent;
+ }
+
+ /** v2 信封({@code fc.answer.delivery.v2} 的消息体) */
+ @Getter
+ @Setter
+ public static class SnapshotEnvelope {
+
+ @JsonProperty("protocolVersion")
+ private String protocolVersion = PROTOCOL_VERSION;
+
+ @JsonProperty("eventId")
+ private String eventId;
+
+ @JsonProperty("eventType")
+ private String eventType;
+
+ /** 发布方(本表单中心)的应用身份,由服务端配置给,不接受消费方申报 */
+ @JsonProperty("sourceAppKey")
+ private String sourceAppKey;
+
+ @JsonProperty("consumerAppKey")
+ private String consumerAppKey;
+
+ @JsonProperty("consumerSchoolId")
+ private Long consumerSchoolId;
+
+ /** 投递行的授权纪元:撤权/能力变更即进位,消费端按它判"这封还作不作数" */
+ @JsonProperty("grantVersion")
+ private Integer grantVersion;
+
+ @JsonProperty("formUuid")
+ private String formUuid;
+
+ @JsonProperty("formVersion")
+ private Integer formVersion;
+
+ @JsonProperty("answerUuid")
+ private String answerUuid;
+
+ @JsonProperty("answerRevision")
+ private Long answerRevision;
+
+ @JsonProperty("isDeleted")
+ private Boolean isDeleted;
+
+ /** {@code fc.canon.v1} 下快照(剔除 intent)的 SHA-256 十六进制 */
+ @JsonProperty("contentHash")
+ private String contentHash;
+
+ @JsonProperty("occurredAt")
+ private String occurredAt;
+
+ @JsonProperty("snapshot")
+ private Snapshot snapshot;
+ }
+
+ // ==================== 提交上下文(A.2/A.4) ====================
+
+ /** 消费方申请可信提交上下文({@code POST /oapi/form/submission-context}) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ContextIssueRequest {
+ /** 消费方业务意图键(服务端生成,浏览器不可注入);同 (appKey,intentId) 重试返回同一上下文 */
+ private String intentId;
+ private Long consumerSchoolId;
+ /** 钉定的表单坐标(必须是服务器校验过的成对值,单传或"看起来对"都拒) */
+ private String formUuid;
+ private Integer formVersion;
+ /** 上下文绑定的填报人(hosted 首批=登录本人;FC 在 redeem 时逐字复核会话用户) */
+ private Integer submitterId;
+ }
+
+ /** 上下文签发结果(token 明文只在这一条响应里出现一次,库里只存摘要) */
+ @Getter
+ @Setter
+ public static class ContextIssueView {
+ private String intentId;
+ private String contextToken;
+ /** 消费方页面的精确 origin(服务端注册信息,浏览器传的一律不用) */
+ private String embedOrigin;
+ /** 消费方确认入口的绝对 URL(同上) */
+ private String callbackUrl;
+ private String formUuid;
+ private Integer formVersion;
+ private String appCode;
+ private String code;
+ private String formName;
+ private String expiresAt;
+ /** 同一意图重试命中原上下文=true(不新建第二个业务意图) */
+ private Boolean reused;
+ }
+
+ /** 上下文收据({@code GET /oapi/form/submission-context/{intentId}/receipt}) */
+ @Getter
+ @Setter
+ public static class ContextReceiptView {
+ /** PENDING=还没答卷;SUBMITTED=已原子关联答卷 */
+ private String status;
+ private String intentId;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private Integer submitterId;
+ private String formUuid;
+ private Integer formVersion;
+ private String answerUuid;
+ private Long answerRevision;
+ private String contentHash;
+ private String submittedAt;
+ private Boolean expired;
+ /** 收据带快照:可信建件不依赖"MQ 恰好已投递"(本地无 broker 时这条是唯一通路) */
+ private Snapshot snapshot;
+ }
+
+ // ==================== 投递回执与授权核验(B.6) ====================
+
+ /** 消费方应用结果回执({@code POST /oapi/form/answer-delivery/receipt}) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class AppliedReport {
+ private String eventId;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String answerUuid;
+ private Long answerRevision;
+ /** APPLIED=本地已落库;IGNORED=按 revision 判定无需应用;CONFLICT=同版异 hash */
+ private String outcome;
+ /** 消费端复算的 hash(与信封一致才可能判 APPLIED) */
+ private String contentHash;
+ /** 消费端本地引用(办件号之类),只作审计,不参与幂等判据 */
+ private String localRef;
+ }
+
+ /** 回执处理结果(幂等:重复上报同一 eventId+consumer 返回原结论) */
+ @Getter
+ @Setter
+ public static class AppliedReportView {
+ private String eventId;
+ private String state;
+ private Boolean duplicate;
+ private String brokerAcceptedAt;
+ private String consumerAppliedAt;
+ }
+
+ /** 授权快照核验({@code POST /oapi/form/answer-delivery/grants/verify},批量、不回查值文档) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class GrantVerifyRequest {
+ private Long consumerSchoolId;
+ private List formUuids = new ArrayList<>();
+ }
+
+ /** 单张表单的授权快照(消费端带 TTL 缓存它;查不到就是"当前不生效") */
+ @Getter
+ @Setter
+ public static class GrantSnapshotView {
+ private String formUuid;
+ private Long consumerSchoolId;
+ private String consumerAppKey;
+ private Boolean subscribeAllowed;
+ private Integer grantVersion;
+ /** 核验时点(ISO),消费端据此算缓存有效期 */
+ private String verifiedAt;
+ }
+
+ // ==================== 投递状态与 TASK_3 扩展点出参 ====================
+
+ /** 快照视图({@code AnswerSnapshotService.currentAuthorized}) */
+ @Getter
+ @Setter
+ public static class SnapshotView {
+ private String answerUuid;
+ private Long answerRevision;
+ private String contentHash;
+ private Boolean isDeleted;
+ private Snapshot snapshot;
+ }
+
+ /** 投递状态视图({@code replayAuthorized} 的返回;稳定状态码见枚举注释) */
+ @Getter
+ @Setter
+ public static class DeliveryStateView {
+ /**
+ * 稳定状态码(TASK_3 只调用不编辑,故码值不随文案变):
+ * {@code QUEUED}=已重新入队;{@code ALREADY_DELIVERED}=行已 brokerAccepted 且无异常;
+ * {@code GRANT_REVOKED}=当前授权不生效,不重放旧 grant;
+ * {@code REVISION_MISMATCH}=期望 revision 与库内当前值不符(历史版本内容不可重建)。
+ */
+ private String state;
+ private String eventId;
+ private Long answerRevision;
+ private String message;
+ }
+
+ /** 失败投递分页({@code failedPage}) */
+ @Getter
+ @Setter
+ public static class FailedDeliveryPage {
+ private long total;
+ private List rows = new ArrayList<>();
+ }
+
+ /** 单条失败投递(含死信形态,运维可查) */
+ @Getter
+ @Setter
+ public static class FailedDeliveryView {
+ private String eventId;
+ private String answerUuid;
+ private Long answerRevision;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String state;
+ private Integer retryCount;
+ private String lastError;
+ private String nextRetryAt;
+ private String brokerAcceptedAt;
+ private String consumerAppliedAt;
+ }
+
+ /** hosted 填报页元数据({@code GET /api/form/hosted/schema}) */
+ @Getter
+ @Setter
+ public static class HostedFormView {
+ private String formUuid;
+ private String appCode;
+ private String code;
+ private String name;
+ /** 上下文钉住的确切版本(hosted 永不回落到当前发布版) */
+ private Integer version;
+ private List audience;
+ private Integer fillLimit;
+ private String deadline;
+ private Boolean deadlineReached;
+ private Object groups;
+ private String expiresAt;
+ /** 消费方 origin:填报页 postMessage 的唯一允许目标(精确到 scheme+host+port) */
+ private String embedOrigin;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/model/AnswerModels.java b/src/main/java/com/yunhai/formcenter/model/AnswerModels.java
index 0ac79a5..0f1ca70 100644
--- a/src/main/java/com/yunhai/formcenter/model/AnswerModels.java
+++ b/src/main/java/com/yunhai/formcenter/model/AnswerModels.java
@@ -37,6 +37,14 @@ public final class AnswerModels {
private String idempotencyKey;
/** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */
private Integer subjectId;
+ /**
+ * 可选:可信提交上下文令牌(v2 契约 §6.3,TASK_2 A.3)。带它的提交就是 hosted 链路——
+ * 中心在答卷事务内兑换令牌、按令牌锁定的 {@code formUuid+formVersion} 钉版、
+ * 复核会话用户/学校,并把答卷原子关联回消费方的业务意图。
+ * 它不是身份载体:{@code schId/submitterId} 仍只由会话推导(V17 不变),
+ * 令牌里断言的人与会话不符即拒;也没有 bizId 位——浏览器传不进办件坐标。
+ */
+ private String contextToken;
/** 值文档 {@code {key:value}},形状见契约 §3 */
private JsonNode values;
}
diff --git a/src/main/java/com/yunhai/formcenter/model/FormAccessModels.java b/src/main/java/com/yunhai/formcenter/model/FormAccessModels.java
new file mode 100644
index 0000000..ea0fa8f
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/FormAccessModels.java
@@ -0,0 +1,127 @@
+package com.yunhai.formcenter.model;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * 消费应用授权与目录(v3,TASK_1)的模型。
+ *
+ * 三条不外露的线:库内 {@code id}(契约 §1/V5 同口径,寻址一律 uuid + 三元键)、
+ * 任何 appSecret(授权判定只用平台密钥注册表验"这个 appKey 存不存在",绝不回显密钥)、
+ * 归属差别信息(跨校/缺归属/不存在同一句拒绝,见 {@code FormConsumerAccessService})。
+ */
+public final class FormAccessModels {
+
+ private FormAccessModels() {
+ }
+
+ /**
+ * 已验签的消费应用身份。唯一字段是 {@code appKey}——它由平台 {@code AppAuthFilter} 验签通过后
+ * 写在请求属性 {@code yh.app.auth} 上,本应用只是转读,没有任何"客户端自报身份"的入口。
+ *
+ * {@code equals} 是给单测的透传断言用的(controller 层"只传不改"这件事要能按值比对),
+ * 不是给业务判权用的。
+ */
+ @Getter
+ @Setter
+ @lombok.EqualsAndHashCode
+ public static class ConsumerPrincipal {
+ private String appKey;
+
+ public ConsumerPrincipal() {
+ }
+
+ public ConsumerPrincipal(String appKey) {
+ this.appKey = appKey;
+ }
+ }
+
+ /** 订阅授权(v3 §7):{@code grantVersion} 必须随投递下发,消费方据此识别"这份授权已作废" */
+ @Getter
+ @Setter
+ public static class SubscriptionGrant {
+ private String formUuid;
+ private Long consumerSchoolId;
+ private String consumerAppKey;
+ private boolean readAllowed;
+ private boolean subscribeAllowed;
+ private int grantVersion;
+ }
+
+ // ==================== 目录(GET /oapi/form/forms) ====================
+
+ /** 目录行:够消费方选择器用的最小坐标集,选中后按 {@code formUuid}+{@code version} 存绑定 */
+ @Getter
+ @Setter
+ public static class CatalogItem {
+ private String formUuid;
+ private String code;
+ private String name;
+ private String appCode;
+ /** 当前已发布版本号(目录只出已发布行,所以它必然非空) */
+ private Integer version;
+ /** 面向人群(契约 v2 C5 形态:字符串数组) */
+ private List audience;
+ }
+
+ /** 目录分页载荷:{@code {items,total}},{@code total}=过滤后的可读集大小(不是库内行数) */
+ @Getter
+ @Setter
+ public static class CatalogPage {
+ private List items;
+ private long total;
+ }
+
+ // ==================== 管理面 ====================
+
+ /** owner 绑定视图 */
+ @Getter
+ @Setter
+ public static class OwnershipView {
+ private String formUuid;
+ private Long formSchoolId;
+ private String ownerAppKey;
+ }
+
+ /** owner 绑定请求:只有 appKey 一件——归属校由服务端按表单自己的 {@code sch_id} 落,不信入参 */
+ @Getter
+ @Setter
+ public static class OwnerRequest {
+ private String ownerAppKey;
+ }
+
+ /** grant 视图(含已撤销行) */
+ @Getter
+ @Setter
+ public static class GrantView {
+ private String formUuid;
+ private Long consumerSchoolId;
+ private String consumerAppKey;
+ private boolean readAllowed;
+ private boolean subscribeAllowed;
+ private boolean enabled;
+ private int grantVersion;
+ }
+
+ /** grant 授权请求(三元键 + 两把开关;不传开关=只给读,默认拒绝) */
+ @Getter
+ @Setter
+ public static class GrantRequest {
+ private String formUuid;
+ private Long consumerSchoolId;
+ private String consumerAppKey;
+ private Boolean readAllowed;
+ private Boolean subscribeAllowed;
+ }
+
+ /** grant 撤销请求 */
+ @Getter
+ @Setter
+ public static class RevokeRequest {
+ private String formUuid;
+ private Long consumerSchoolId;
+ private String consumerAppKey;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java b/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java
new file mode 100644
index 0000000..8d281db
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java
@@ -0,0 +1,306 @@
+package com.yunhai.formcenter.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 差异对账协议载荷(TASK_3,answer-reconcile-v1 §6)。
+ *
+ * 独立 DTO:不往 {@code OapiModels}(TASK_1)与 {@code AnswerDeliveryModels}(TASK_2)里塞字段,
+ * 那两个文件的形状由各自任务负责。唯一的跨任务共享类型是
+ * {@link AnswerDeliveryModels.SnapshotEnvelope}/{@link AnswerDeliveryModels.Snapshot}——
+ * 修复信封必须与投递信封同一条规范化口径,复制一份就给了哈希分叉机会。
+ *
+ * {@code values} 一律只出现在 {@code Snapshot} 里、由签名任务端点按授权下发;
+ * 本文件的任何回报结构都不带值文档(§8 敏感数据纪律:错误明细里也不许出现)。
+ */
+public final class FormSyncModels {
+
+ /** 协议号:锁死"清单游标=answerUuid、终态闭集、缺席需证明"这三条本轮约定 */
+ public static final String PROTOCOL = "fc.form-sync.v1";
+
+ /** 任务快照答复的存在性结论 */
+ public static final String PRESENCE_PRESENT = "PRESENT";
+ public static final String PRESENCE_ABSENT = "ABSENT";
+ public static final String PRESENCE_UNKNOWN = "UNKNOWN";
+
+ private FormSyncModels() {
+ }
+
+ // ==================== FC → SH:派发指令 ====================
+
+ /**
+ * 派发指令({@code POST /oapi/form-sync/reconcile} 的请求体)。
+ *
+ * 只带任务坐标与清单规模,不带任何答卷数据;地址来自服务端登记的后台服务地址,
+ * 浏览器提供的 callbackUrl 在这里没有任何对应字段。
+ */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ReconcileInstruction {
+ private String protocol = PROTOCOL;
+ /** 任务对外身份(task_uuid) */
+ private String taskId;
+ /** 本次派发尝试(结果幂等键的另一半) */
+ private Integer attempt;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String formUuid;
+ private Integer formVersion;
+ /** SCHEDULE / MANUAL */
+ private String triggerType;
+ /** 任务起点(yyyy-MM-dd'T'HH:mm:ss):本地 createTime 晚于此刻的行不得判为多余行 */
+ private String windowStart;
+ /** 清单物化完成时刻 */
+ private String materializedAt;
+ private Integer totalRows;
+ private Integer totalPages;
+ /** 建议页大小:消费端本地扫描与中心清单分页都用它,锁步比较才对得上 */
+ private Integer pageSize;
+ private String issuedAt;
+ }
+
+ /** 派发应答(对端 2xx 只说明指令被收下并持久化,不代表执行结果) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class InstructionAck {
+ private String taskId;
+ private Integer attempt;
+ private Boolean accepted;
+ /** 同 taskId 重复指令:已接收过,复用原任务与检查点 */
+ private Boolean duplicate;
+ private String state;
+ private String message;
+ }
+
+ // ==================== SH → FC:清单 / 快照 / 缺席 / 结果 ====================
+
+ /** 清单行(四元摘要 + 中心已核验的表单坐标) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ManifestRowView {
+ private String answerUuid;
+ private Long revision;
+ private String contentHash;
+ private Boolean isDeleted;
+ private String formUuid;
+ private Integer formVersion;
+ }
+
+ /**
+ * 清单分页:{@code completed=true} 才是集合结束,空页不是。
+ * 游标是最后一个 answerUuid,与消费端本地扫描同序、同语义。
+ */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ManifestPageView {
+ private String taskId;
+ private List rows = new ArrayList<>();
+ private String nextCursor;
+ private Boolean completed;
+ private Integer totalRows;
+ private Integer totalPages;
+ /** 任务当前状态(CANCELLED 时消费端须停止剩余修复) */
+ private String taskState;
+ }
+
+ /**
+ * 任务快照答复:修复入口唯一的权威来源。
+ *
+ * {@code presence=PRESENT} 时带 {@code envelope}(服务端按已核验坐标构造,
+ * formUuid/formVersion 来自任务行与答卷行,不从旧清单猜);
+ * {@code ABSENT} 时带 {@code absenceProof};{@code UNKNOWN}(清单未完整、期间新增、
+ * 范围改变、撤权)时两者都不给,只给原因——消费端据此只能报告差异,不能破坏本地数据。
+ */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class TaskAnswerReply {
+ private String taskId;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String formUuid;
+ private Integer formVersion;
+ private String answerUuid;
+ private String presence;
+ private Integer grantVersion;
+ private AnswerDeliveryModels.SnapshotEnvelope envelope;
+ private AbsenceProof absenceProof;
+ /** 无法给出权威结论时的稳定原因码(MANIFEST_INCOMPLETE / GRANT_REVOKED / CANCELLED / …) */
+ private String reasonCode;
+ private String reason;
+ private String verifiedAt;
+ }
+
+ /**
+ * 权威缺席证明(只在完整中心清单 + 新鲜授权核验都通过后签发)。
+ * {@code proofId} 是可追踪引用,不是身份凭证;{@code expectedRevision} 必填——
+ * 版本对不上本地行的退役请求会被消费端拒绝。
+ */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class AbsenceProof {
+ private String proofId;
+ private String taskId;
+ private String formUuid;
+ private Long schId;
+ private String answerUuid;
+ private Long expectedRevision;
+ private Integer grantVersion;
+ private String issuedAt;
+ }
+
+ /** 结果回报({@code POST /oapi/form-sync/tasks/{taskId}/result} 请求体) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ResultReport {
+ private String protocol = PROTOCOL;
+ private Integer attempt;
+ /** COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报) */
+ private String state;
+ private Boolean localScanComplete;
+ private Boolean manifestComplete;
+ private Integer scannedRows;
+ private Integer pagesProcessed;
+ private Integer missingRepaired;
+ private Integer staleRepaired;
+ private Integer hashConflicts;
+ private Integer tombstonesRepaired;
+ private Integer localAhead;
+ private Integer deferred;
+ private Integer extraReported;
+ private Integer extraRetired;
+ /** 进程重启后从检查点续跑 */
+ private Boolean resumed;
+ private String startedAt;
+ private List errors = new ArrayList<>();
+ }
+
+ /** 一条错误:consumer / task / range / answer 四个坐标必须在场,值文档绝不在场 */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ReconcileError {
+ private String code;
+ private String taskId;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String formUuid;
+ private String answerUuid;
+ private Long revision;
+ private String detail;
+ }
+
+ /** 结果受理答复:幂等与"旧 attempt 不倒退新 attempt"的结论都在这里回给消费端 */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ResultAck {
+ private String taskId;
+ private Integer attempt;
+ private Boolean accepted;
+ /** 同 taskId+attempt 的重复回报:原结论回给对端,不重复计数 */
+ private Boolean duplicate;
+ /** 比中心已记录的最新 attempt 更旧:留档但不推进状态 */
+ private Boolean staleAttempt;
+ private String taskState;
+ private String message;
+ }
+
+ // ==================== 管理面 ====================
+
+ /** 建任务请求:三种范围(单答卷 / 单表单 / 该消费方全部生效订阅)走同一条入口 */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class TaskCreateRequest {
+ /** ANSWER / FORM / ALL */
+ private String scope;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String formUuid;
+ private String answerUuid;
+ /** 人工触发的幂等键(前端每次点击生成一个;缺键即由服务端补 uuid) */
+ private String idempotencyKey;
+ }
+
+ /** 任务视图(列表与详情共用) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class TaskView {
+ private String taskId;
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String formUuid;
+ private Integer formVersion;
+ private String triggerType;
+ private String operator;
+ private String state;
+ private String manifestState;
+ private Boolean manifestComplete;
+ private Integer totalRows;
+ private Integer totalPages;
+ private Integer materializedRows;
+ private Integer materializedPages;
+ private Integer attempt;
+ private String windowStart;
+ private String windowEnd;
+ private String dispatchedAt;
+ private String finishedAt;
+ private Boolean cancelRequested;
+ private String errorCode;
+ private String errorDetail;
+ /** 最近一次回报摘要(进度/差异计数) */
+ private ResultView latestResult;
+ }
+
+ /** 结果视图 */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ResultView {
+ private String taskId;
+ private Integer attempt;
+ private String state;
+ private Boolean localScanComplete;
+ private Boolean manifestComplete;
+ private Integer scannedRows;
+ private Integer pagesProcessed;
+ private Integer missingRepaired;
+ private Integer staleRepaired;
+ private Integer hashConflicts;
+ private Integer tombstonesRepaired;
+ private Integer localAhead;
+ private Integer deferred;
+ private Integer extraReported;
+ private Integer extraRetired;
+ private Integer errorCount;
+ private List errors = new ArrayList<>();
+ private Boolean resumed;
+ private String reportedAt;
+ }
+
+ /** 单答卷重放请求(管理面→TASK_2 {@code replayAuthorized}) */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ReplayRequest {
+ private String consumerAppKey;
+ private Long consumerSchoolId;
+ private String answerUuid;
+ private Long expectedRevision;
+ /** 操作幂等键:同一动作重复点击不刷新退避 */
+ private String operationId;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerDeliveryRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerDeliveryRepository.java
new file mode 100644
index 0000000..3eac26d
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerDeliveryRepository.java
@@ -0,0 +1,48 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.AnswerDelivery;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 投递行仓库(outbox,TASK_2 B.3/B.4)。
+ *
+ * 只提供扫描/定位/分页三类取数,不提供任何删除方法:投递行是审计资产,
+ * 终态(DEAD/GRANT_REVOKED)留在表里供对账与人工处置,与答卷"无物理删除"同一条纪律
+ * ({@code AnswerRepository} 类头)。
+ */
+public interface AnswerDeliveryRepository extends JpaRepository {
+
+ /** 回执端点定位(eventId 全局唯一,另三键做逐字复核) */
+ Optional findByEventId(String eventId);
+
+ /** 同 revision 同消费方是否已有行(入队幂等;正常由 uk_delivery_revision 兜底) */
+ Optional findByAnswerUuidAndAnswerRevisionAndConsumerAppKeyAndConsumerSchoolId(
+ String answerUuid, Long answerRevision, String consumerAppKey, Long consumerSchoolId);
+
+ List findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(String answerUuid, String consumerAppKey);
+
+ /** 到点待投(PENDING 首投与 FAILED 退避重试同一条队列) */
+ @Query("select d from AnswerDelivery d where d.state in ('PENDING', 'FAILED') "
+ + "and (d.nextRetryAt is null or d.nextRetryAt <= :now) order by d.id asc")
+ List findDue(@Param("now") LocalDateTime now, Pageable pageable);
+
+ /** 失败/死信分页({@code failedPage},TASK_3 对账用) */
+ @Query("select d from AnswerDelivery d where d.consumerAppKey = :appKey "
+ + "and d.consumerSchoolId = :schoolId and d.state in ('FAILED', 'DEAD') order by d.id desc")
+ Page findFailedForConsumer(@Param("appKey") String appKey,
+ @Param("schoolId") Long schoolId, Pageable pageable);
+
+ /** 该答卷是否还有未落定投递(墓碑/回放前的存在性判定) */
+ boolean existsByAnswerUuidAndStateIn(String answerUuid, List states);
+
+ @Query("select count(d) from AnswerDelivery d where d.state = :state")
+ long countByState(@Param("state") String state);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileManifestRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileManifestRepository.java
new file mode 100644
index 0000000..72107b6
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileManifestRepository.java
@@ -0,0 +1,35 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.AnswerReconcileManifestRow;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 任务 manifest 读取(TASK_3)。
+ *
+ * 只读接口 + 批量插入:没有 update、没有 delete。清单一旦物化就是这一轮对账的事实源,
+ * 分页读它、缺席判定也读它;{@code answer} 表此后怎么变都不影响本轮结论(这正是它存在的理由)。
+ */
+public interface AnswerReconcileManifestRepository extends JpaRepository {
+
+ /** 清单分页:游标=上一个 answerUuid(32 位十六进制),不是页码、不是库内 id */
+ @Query("select m from AnswerReconcileManifestRow m where m.taskId = :taskId and m.answerUuid > :cursor "
+ + "order by m.answerUuid asc")
+ List findPage(@Param("taskId") Long taskId, @Param("cursor") String cursor,
+ Pageable pageable);
+
+ Optional findByTaskIdAndAnswerUuid(Long taskId, String answerUuid);
+
+ boolean existsByTaskIdAndAnswerUuid(Long taskId, String answerUuid);
+
+ long countByTaskId(Long taskId);
+
+ /** 断点续作物化前的自检:这一轮已经写了多少行(checkpoint 丢失时的兜底口径) */
+ @Query("select coalesce(max(m.seqNo), -1) from AnswerReconcileManifestRow m where m.taskId = :taskId")
+ int maxSeqNo(@Param("taskId") Long taskId);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileReadRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileReadRepository.java
new file mode 100644
index 0000000..f3b5d24
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileReadRepository.java
@@ -0,0 +1,44 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.entity.FormConsumerGrant;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+/**
+ * 对账专用只读仓库(TASK_3 改动点 2/6:"摘要读取用本任务新增只读 Repository/投影")。
+ *
+ * 存在的理由有两层:
+ *
+ * - 不给 TASK_2 的文件加快捷查询——{@code AnswerRepository} 是投递与数据面共用的门面,
+ * 对账要的"按 uuid 升序整表扫"只在物化时用一个瞬间,塞进共用门面就是污染;
+ * - 订阅清单的枚举口径归本任务——TASK_1 的 {@code FormConsumerGrantRepository} 只提供
+ * 按表单取订阅(投递路径的形态),调度要的是"全校全表单的生效订阅游标",这是本任务的读取面,
+ * 条件仍与 TASK_1 逐字一致({@code enabled=1 and subscribeAllowed=1})。
+ *
+ *
+ * 返回 {@code Answer} 实体而不是投影:清单摘要里的 hash 必须由
+ * {@code AnswerSnapshotService#contentHash} 按 {@code fc.canon.v1} 现算,而它要完整值文档。
+ * 取数一律带 keyset 游标与 {@code Pageable} 上限,任何方法都不允许无界拉全表。
+ */
+public interface AnswerReconcileReadRepository extends JpaRepository {
+
+ /** 物化取数:按 (表单, 消费租户域) 的 uuid 升序 keyset,墓碑一并出(清单含缺席事实) */
+ @Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schoolId "
+ + "and a.uuid > :cursor order by a.uuid asc")
+ List findScopeAfterUuid(@Param("formUuid") String formUuid, @Param("schoolId") Long schoolId,
+ @Param("cursor") String cursor, Pageable pageable);
+
+ /** 建任务前的资源预算:范围内答卷总数(含墓碑),超限即拒绝建任务而不是当场载表 */
+ @Query("select count(a.id) from Answer a where a.formUuid = :formUuid and a.schId = :schoolId")
+ long countScope(@Param("formUuid") String formUuid, @Param("schoolId") Long schoolId);
+
+ /** 调度枚举生效订阅(游标=id,升序):判据与 TASK_1 的 findEnabledSubscriptions 同一条 */
+ @Query("select g from FormConsumerGrant g where g.enabled = 1 and g.subscribeAllowed = 1 "
+ + "and g.readAllowed = 1 and g.id > :cursor order by g.id asc")
+ List findLiveSubscriptionsAfterId(@Param("cursor") Long cursor, Pageable pageable);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileResultRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileResultRepository.java
new file mode 100644
index 0000000..9e58091
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileResultRepository.java
@@ -0,0 +1,36 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.AnswerReconcileResult;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 对账结果仓库(TASK_3)。
+ *
+ * 一 attempt 一行、{@code (task_id, attempt)} 唯一:重复回报由服务层读回原行按幂等吸收,
+ * 不做覆盖更新;比最新 attempt 更旧的回报由 {@link #findLatest} 的返回值判方向后拒绝推进。
+ */
+public interface AnswerReconcileResultRepository extends JpaRepository {
+
+ Optional findByTaskIdAndAttempt(Long taskId, Integer attempt);
+
+ Optional findFirstByTaskIdOrderByIdDesc(Long taskId);
+
+ @Query("select max(r.attempt) from AnswerReconcileResult r where r.taskId = :taskId")
+ Integer maxAttempt(@Param("taskId") Long taskId);
+
+ @Query("select r from AnswerReconcileResult r where r.taskId = :taskId order by r.attempt asc")
+ List findByTask(@Param("taskId") Long taskId);
+
+ /** 失败明细分页(管理面查看与重放入口的取数) */
+ @Query("select r from AnswerReconcileResult r where r.consumerAppKey = :appKey "
+ + "and r.consumerSchoolId = :schoolId and r.state in ('PARTIAL', 'FAILED') order by r.id desc")
+ Page findUnresolved(@Param("appKey") String appKey,
+ @Param("schoolId") Long schoolId, Pageable pageable);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileTaskRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileTaskRepository.java
new file mode 100644
index 0000000..bd34bfc
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileTaskRepository.java
@@ -0,0 +1,162 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.AnswerReconcileTask;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import javax.persistence.LockModeType;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 对账任务仓库(TASK_3)。
+ *
+ * 三件事在这里定型:①领取用条件更新而不是"读—改—写"(两个扫描线程同时看到同一条
+ * QUEUED 时,只有一个能刷新租约,另一个更新 0 行即退出——这就是任务书要求的单并发);
+ * ②同一坐标只允许一个活跃任务({@code countActiveAt} 是它的判据);
+ * ③没有任何删除方法:任务、清单、结果都是审计资产,终态留表供复盘。
+ */
+public interface AnswerReconcileTaskRepository extends JpaRepository {
+
+ Optional findByTaskUuid(String taskUuid);
+
+ Optional findByIdempotencyKey(String idempotencyKey);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select t from AnswerReconcileTask t where t.id = :id")
+ Optional findByIdForUpdate(@Param("id") Long id);
+
+ /** 待物化(建任务后由扫描异步物化,全量人工任务因此不会在请求线程里载表) */
+ @Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' "
+ + "and t.manifestState = 'PENDING' and t.cancelRequested = 0 order by t.id asc")
+ List findPendingMaterialization(Pageable pageable);
+
+ /** 物化中断后需续跑的半成品(checkpoint 非空,仍在跑的那条租约未到期不会被领走) */
+ @Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' "
+ + "and t.manifestState = 'MATERIALIZING' and (t.leaseUntil is null or t.leaseUntil <= :now) "
+ + "and t.cancelRequested = 0 order by t.id asc")
+ List findStaleMaterialization(@Param("now") LocalDateTime now, Pageable pageable);
+
+ /** 清单已完整、到点可派发(首派与退避重派同一条队列) */
+ @Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' and t.manifestComplete = 1 "
+ + "and t.cancelRequested = 0 and (t.nextDispatchAt is null or t.nextDispatchAt <= :now) "
+ + "order by t.id asc")
+ List findDispatchable(@Param("now") LocalDateTime now, Pageable pageable);
+
+ /** 租约超期:按 attempt+1 有限退避重派(业务结果仍按 taskId/attempt 幂等) */
+ @Query("select t from AnswerReconcileTask t where t.state in ('DISPATCHED', 'RUNNING') "
+ + "and t.leaseUntil is not null and t.leaseUntil <= :now and t.cancelRequested = 0 "
+ + "order by t.id asc")
+ List findExpiredLease(@Param("now") LocalDateTime now, Pageable pageable);
+
+ /** 同坐标活跃任务数(单并发建任务判据) */
+ @Query("select count(t) from AnswerReconcileTask t where t.consumerAppKey = :appKey "
+ + "and t.consumerSchoolId = :schoolId and t.formUuid = :formUuid "
+ + "and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')")
+ long countActiveAt(@Param("appKey") String appKey, @Param("schoolId") Long schoolId,
+ @Param("formUuid") String formUuid);
+
+ /**
+ * 同一消费方当前在途(已派发或执行中)的任务数——全量人工任务"单并发"的判据:
+ * 一个消费者一次只跑一轮对账,避免两个 Worker 同时扫同一份本地集合互相踩检查点。
+ */
+ @Query("select count(t) from AnswerReconcileTask t where t.consumerAppKey = :appKey "
+ + "and t.consumerSchoolId = :schoolId and t.state in ('DISPATCHED', 'RUNNING') "
+ + "and t.id <> :exceptId")
+ long countInFlightForConsumer(@Param("appKey") String appKey, @Param("schoolId") Long schoolId,
+ @Param("exceptId") Long exceptId);
+
+ /** 管理面列表:按消费域(超管可看全部,校级只看本域) */
+ @Query("select t from AnswerReconcileTask t where (:appKey is null or t.consumerAppKey = :appKey) "
+ + "and (:schoolId is null or t.consumerSchoolId = :schoolId) "
+ + "and (:state is null or t.state = :state) order by t.id desc")
+ Page findForAdmin(@Param("appKey") String appKey, @Param("schoolId") Long schoolId,
+ @Param("state") String state, Pageable pageable);
+
+ // ===== 条件更新(并发只落一次)=====
+
+ /** 领取物化权:PENDING 或超期的 MATERIALIZING 才能被领走 */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.manifestState = 'MATERIALIZING', t.leaseUntil = :leaseUntil "
+ + "where t.id = :id and t.state = 'QUEUED' and t.cancelRequested = 0 "
+ + "and (t.manifestState = 'PENDING' or ((t.manifestState = 'MATERIALIZING') "
+ + "and (t.leaseUntil is null or t.leaseUntil <= :now)))")
+ int claimMaterialization(@Param("id") Long id, @Param("now") LocalDateTime now,
+ @Param("leaseUntil") LocalDateTime leaseUntil);
+
+ /** 领取派发权:只有租约到期或还没派过的行会被领走 */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.state = 'DISPATCHED', t.attempt = t.attempt + 1, "
+ + "t.leaseUntil = :leaseUntil, t.dispatchedAt = :now, t.nextDispatchAt = null "
+ + "where t.id = :id and t.state = 'QUEUED' and t.manifestComplete = 1 and t.cancelRequested = 0 "
+ + "and (t.nextDispatchAt is null or t.nextDispatchAt <= :now)")
+ int claimForDispatch(@Param("id") Long id, @Param("now") LocalDateTime now,
+ @Param("leaseUntil") LocalDateTime leaseUntil);
+
+ /** 派发失败后退避重排(不占用 attempt 之外的次数预算) */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.state = 'QUEUED', t.nextDispatchAt = :nextAt, "
+ + "t.leaseUntil = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ + "where t.id = :id and t.state = 'DISPATCHED'")
+ int rescheduleDispatch(@Param("id") Long id, @Param("nextAt") LocalDateTime nextAt,
+ @Param("errorCode") String errorCode, @Param("detail") String detail);
+
+ /** 租约定期续租(消费端进度回报会带上 taskId/attempt,中心据此刷新) */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.leaseUntil = :leaseUntil, t.state = 'RUNNING' "
+ + "where t.id = :id and t.state in ('DISPATCHED', 'RUNNING')")
+ int markRunning(@Param("id") Long id, @Param("leaseUntil") LocalDateTime leaseUntil);
+
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.leaseUntil = :leaseUntil, t.state = 'RUNNING' "
+ + "where t.id = :id and t.attempt = :attempt and t.cancelRequested = 0 "
+ + "and t.state in ('DISPATCHED', 'RUNNING')")
+ int renewRunning(@Param("id") Long id, @Param("attempt") Integer attempt,
+ @Param("leaseUntil") LocalDateTime leaseUntil);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select t from AnswerReconcileTask t where t.taskUuid = :uuid")
+ Optional lockByTaskUuid(@Param("uuid") String uuid);
+
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.state = :newState, t.finishedAt = :now, "
+ + "t.leaseUntil = null, t.nextDispatchAt = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ + "where t.id = :id and t.attempt = :attempt and t.cancelRequested = 0 "
+ + "and t.state in ('DISPATCHED', 'RUNNING')")
+ int finishAttempt(@Param("id") Long id, @Param("attempt") Integer attempt,
+ @Param("newState") String newState, @Param("now") LocalDateTime now,
+ @Param("errorCode") String errorCode, @Param("detail") String detail);
+
+ /** 终态推进:只有非终态行会被改写(重复回报幂等,不由这里保证——这里只防倒退覆盖) */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.state = :newState, t.finishedAt = :now, "
+ + "t.leaseUntil = null, t.nextDispatchAt = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ + "where t.id = :id and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')")
+ int advanceTerminal(@Param("id") Long id, @Param("newState") String newState,
+ @Param("now") LocalDateTime now, @Param("errorCode") String errorCode,
+ @Param("detail") String detail);
+
+ /** 租约超期重排:DISPATCHED/RUNNING 回到 QUEUED 等下一次退避派发 */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.state = 'QUEUED', t.nextDispatchAt = :nextAt, "
+ + "t.leaseUntil = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ + "where t.id = :id and t.state in ('DISPATCHED', 'RUNNING') and (t.leaseUntil is null "
+ + "or t.leaseUntil <= :now)")
+ int requeueExpiredLease(@Param("id") Long id, @Param("now") LocalDateTime now,
+ @Param("nextAt") LocalDateTime nextAt, @Param("errorCode") String errorCode,
+ @Param("detail") String detail);
+
+ /** 取消收敛:非终态一律落 CANCELLED(终态行更新 0 行,取消动作因此是幂等的) */
+ @Modifying(clearAutomatically = true)
+ @Query("update AnswerReconcileTask t set t.state = 'CANCELLED', t.cancelRequested = 1, "
+ + "t.finishedAt = :now, t.leaseUntil = null, t.nextDispatchAt = null, "
+ + "t.errorCode = 'CANCELLED', t.errorDetail = :detail "
+ + "where t.id = :id and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')")
+ int markCancelled(@Param("id") Long id, @Param("now") LocalDateTime now, @Param("detail") String detail);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
index f516b24..bc2178f 100644
--- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
@@ -3,6 +3,7 @@ package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Answer;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -25,6 +26,21 @@ public interface AnswerRepository extends JpaRepository {
Optional findByUuidAndSchId(String uuid, Long schId);
+ /**
+ * 快照修订号行级 CAS(v2 投递契约 §2,TASK_2 B.1 的并发控制落点)。
+ *
+ * {@code UPDATE} 自身拿行锁:并发改同一条答卷时,后到的事务在这里被挡到前一个提交,
+ * 回来后 {@code WHERE answer_revision = :expected} 不再匹配 → 返回 0 行 → 调用方抛异常整事务回滚。
+ * 与 outbox 的 {@code uk_delivery_revision} 是同一条不变式的两道闸:CAS 挡"取到同一个号",
+ * 唯一键挡"已经写出去的两行同版不同内容"。
+ *
+ * 必须在改动实体的 {@code answerRevision} 字段之前调用:JPQL 批量更新绕过脏检查,
+ * 先改字段会让自动 flush 把新号写进去、CAS 再也匹配不上旧号(自我拒绝)。
+ */
+ @Modifying
+ @Query("update Answer a set a.answerRevision = :next where a.id = :id and a.answerRevision = :expected")
+ int compareAndSetRevision(@Param("id") Long id, @Param("expected") Long expected, @Param("next") Long next);
+
/**
* 按 (域, uuid 集合) 批量定位(T14 守卫:数据面入口要按答卷反查它所属的表单才能判 ALL 档)。
* 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormConsumerGrantRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormConsumerGrantRepository.java
new file mode 100644
index 0000000..36f86ca
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/FormConsumerGrantRepository.java
@@ -0,0 +1,40 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.FormConsumerGrant;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 消费授权仓库(v3 §2)。三元唯一键就是单点定位入口;批量查询服务目录端点,
+ * 免得逐张表回库(一校已发布表单量级个位数到几百,同 {@link FormRepository#findLiveBySch} 的量级论证)。
+ *
+ * 撤销用 {@code enabled=0} 表达,所以判授权的查询必须带 enabled 条件——
+ * 漏掉它就会让已撤销的授权继续生效(fail-open),这条是本仓库唯一不能写错的地方。
+ */
+public interface FormConsumerGrantRepository extends JpaRepository {
+
+ Optional findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(
+ String formUuid, Long consumerSchoolId, String consumerAppKey);
+
+ /** 管理端 grant 清单(含已撤销行:撤销后仍要看得见、可重申) */
+ @Query("select g from FormConsumerGrant g where g.formUuid = :formUuid order by g.id asc")
+ List findLiveByForm(@Param("formUuid") String formUuid);
+
+ /** 订阅面取授权:只认生效且开了订阅的行(v3 §7,TASK_3 投递判据同源) */
+ @Query("select g from FormConsumerGrant g where g.formUuid = :formUuid "
+ + "and g.consumerSchoolId = :schoolId and g.enabled = 1 and g.subscribeAllowed = 1")
+ List findEnabledSubscriptions(@Param("formUuid") String formUuid,
+ @Param("schoolId") Long schoolId);
+
+ /** 目录端点批量取某消费方在某校的生效授权(读判定用) */
+ @Query("select g from FormConsumerGrant g where g.formUuid in (:uuids) "
+ + "and g.consumerSchoolId = :schoolId and g.consumerAppKey = :appKey and g.enabled = 1")
+ List findForConsumer(@Param("uuids") Collection uuids,
+ @Param("schoolId") Long schoolId,
+ @Param("appKey") String appKey);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormOwnershipRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormOwnershipRepository.java
new file mode 100644
index 0000000..2cb79c2
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/FormOwnershipRepository.java
@@ -0,0 +1,24 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.FormOwnership;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * owner 绑定仓库(v3 §2)。判定一律按 uuid 取,不带租户条件——租户域复核在
+ * {@code FormConsumerAccessService} 里做(同 {@link FormRepository#findByUuid} 的分工:
+ * uuid 是全局坐标,"这条绑定属不属于当前域"是应用层规则)。
+ */
+public interface FormOwnershipRepository extends JpaRepository {
+
+ Optional findByFormUuid(String formUuid);
+
+ /** 目录端点的批量取绑定(避免逐张表 N+1 查询) */
+ @Query("select o from FormOwnership o where o.formUuid in (:uuids)")
+ List findByFormUuidIn(@Param("uuids") Collection uuids);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/SubmissionContextRepository.java b/src/main/java/com/yunhai/formcenter/repository/SubmissionContextRepository.java
new file mode 100644
index 0000000..19c3219
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/SubmissionContextRepository.java
@@ -0,0 +1,25 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.SubmissionContext;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+/**
+ * 可信提交上下文仓库(TASK_2 A.2/A.3)。
+ *
+ * 定位方式只有三种,各自对应契约里的一条不变式:
+ *
+ * - {@code (consumerAppKey, intentId)}——同意图重试必须回到同一上下文(uk_ctx_intent);
+ * - {@code tokenDigest}——填报页 redeem,只按摘要查,明文不入库(uk_ctx_token_digest);
+ * - {@code answerUuid}——收据端点按答卷反查意图(uk_ctx_answer 保证一对一)。
+ *
+ */
+public interface SubmissionContextRepository extends JpaRepository {
+
+ Optional findByConsumerAppKeyAndIntentId(String consumerAppKey, String intentId);
+
+ Optional findByTokenDigest(String tokenDigest);
+
+ Optional findByAnswerUuid(String answerUuid);
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java
index d93bf25..a708143 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java
@@ -9,6 +9,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.entity.StatusLog;
+import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
@@ -72,6 +73,8 @@ public class AnswerAdminService {
private final AnswerScopeQueryBuilder queryBuilder;
private final TransactionTemplate transactionTemplate;
private final FcEventPublisher events;
+ /** v2 快照投递:取号与 outbox 入队都在每条独立事务内(TASK_2 B.1,含改状态与批量出口) */
+ private final AnswerDeliveryService deliveries;
// ==================== 单条 ====================
@@ -143,11 +146,13 @@ public class AnswerAdminService {
assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson);
answer.setValuesJson(valuesJson);
+ deliveries.nextRevision(answer);
answerRepository.save(answer);
// 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变
indexService.rebuild(answer, schemaJson);
// 事件:非冻结编辑(冻结在上面就拒了,走不到这里)= updated(契约 v2 §6.2 发布点之一)
events.answerUpdated(answer);
+ deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(),
req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")"
: "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary());
@@ -157,6 +162,7 @@ public class AnswerAdminService {
private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) {
Answer answer = requireManageableAnswer(operator, schId, answerUuid);
answer.setIsDeleted(1);
+ deliveries.nextRevision(answer);
answerRepository.save(answer);
indexService.clear(answer.getId());
// 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。
@@ -168,6 +174,8 @@ public class AnswerAdminService {
operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
// 事件:tombstoned(管理面单条与批量共用本方法,所以两个入口一处发布,§6.2)
events.answerTombstoned(answer);
+ // 批量公共出口同样在每条独立事务里取号+入队:不存在"批量只改行不投快照"的旁路
+ deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_TOMBSTONED);
}
private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req,
@@ -188,6 +196,7 @@ public class AnswerAdminService {
}
String oldStatus = answer.getStatus();
answer.setStatus(status);
+ deliveries.nextRevision(answer);
answerRepository.save(answer);
StatusLog entry = new StatusLog();
entry.setAnswerId(answer.getId());
@@ -198,6 +207,9 @@ public class AnswerAdminService {
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS,
operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
+ "(无转移规则校验)");
+ // 任务书点名的漏项防线:status 是对外快照成员,改状态必须同样取号并入队(updated),
+ // 否则"审核已通过"这类事实对消费方永远不可见,而 outbox 看上去还是完整的
+ deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
}
// ==================== 辅助 ====================
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerCanonicalizer.java b/src/main/java/com/yunhai/formcenter/service/AnswerCanonicalizer.java
new file mode 100644
index 0000000..e4aced7
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerCanonicalizer.java
@@ -0,0 +1,188 @@
+package com.yunhai.formcenter.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 答卷快照的规范化与哈希(answer-delivery-v2 §4,TASK_2 B.1)。
+ *
+ * 为什么要有版本化规范化,而不是直接对 {@code valuesJson} 原文求哈希:投递侧要在
+ * "同一 revision 只能对应一份 hash"这条不变式上落库唯一键,消费侧要用同一算法复算来判
+ * "同版异 hash=冲突待核验"。成员顺序取决于来源(值文档是客户端 JSON 原文、身份列是服务端拼装),
+ * 不规范化就会让同一份数据产出两种字节串——那等于把幂等判据建在随机数上。
+ *
+ * {@code fc.canon.v1} 规则闭集(要改语义就升算法号,不许原地改):
+ *
+ * - 对象:键按 UTF-16 代码元字典序({@link String#compareTo})升序递归输出——
+ * 刻意钉比较器而不是"码点序":两端都是 JVM,同一个比较器才逐字一致;
+ * - 数组:顺序保留(多选/排序是业务语义),逐元素递归;
+ * - 数字:{@code decimalValue().stripTrailingZeros().toPlainString()},
+ * 即 {@code 100.0→100}、{@code 1E+2→100},无指数、无多余前导零;
+ * - 字符串:仅转义 {@code " \ 与控制字符}({@code \b\f\n\r\t} 用短转义,
+ * 其余 C0 用"反斜杠 + u + 00 + 两位大写十六进制",非 ASCII(含中文)原样输出,
+ * 哈希按 UTF-8 取字节;
+ * - 空值策略:显式 null 成员保留(缺失键与 {@code "k":null} 是两种事实,
+ * 合并会丢"字段被清空"这次变化);{@code missing}/{@code null} 节点视为不存在的成员;
+ * - 排除项:快照顶层的 {@code intent} 成员不参与哈希——它是"仅对该 consumer 可见"的
+ * 关联块,同 revision 会因为消费方不同而不同,留着就与不变式①直接矛盾;
+ * 投递时间/重试计数/消费端扩展列都是信封或本地列,压根不进快照。
+ *
+ */
+public final class AnswerCanonicalizer {
+
+ /** 规范化算法号(信封里不回带,由 protocolVersion=fc.answer.snapshot.v2 隐含锁定) */
+ public static final String CANONICALIZATION_VERSION = "fc.canon.v1";
+
+ public static final String HASH_ALGORITHM = "SHA-256";
+
+ /** 规则⑥:顶层排除成员 */
+ private static final String EXCLUDED_TOP_LEVEL_MEMBER = "intent";
+
+ private AnswerCanonicalizer() {
+ }
+
+ /** 全量规范化(含 intent 块本身不参与,见规则⑥——两入口同一条剔除口径) */
+ public static String canonical(JsonNode node) {
+ StringBuilder sb = new StringBuilder(256);
+ write(node == null ? null : stripExcluded(node), sb);
+ return sb.toString();
+ }
+
+ /**
+ * 快照哈希:64 位小写十六进制 SHA-256(UTF-8 字节)。
+ * 消费端复算出的值与本值不等而 revision 相等 ⇒ 冲突待核验,不覆盖。
+ */
+ public static String hashSnapshot(JsonNode snapshot) {
+ return sha256Hex(canonical(snapshot));
+ }
+
+ private static JsonNode stripExcluded(JsonNode node) {
+ if (node == null || !node.isObject()) {
+ return node;
+ }
+ ObjectNode copy = ((ObjectNode) node).deepCopy();
+ copy.remove(EXCLUDED_TOP_LEVEL_MEMBER);
+ return copy;
+ }
+
+ private static void write(JsonNode node, StringBuilder sb) {
+ if (node == null || node.isNull() || node.isMissingNode()) {
+ sb.append("null");
+ return;
+ }
+ if (node.isObject()) {
+ List names = new ArrayList<>();
+ Iterator> fields = node.fields();
+ while (fields.hasNext()) {
+ names.add(fields.next().getKey());
+ }
+ names.sort(String::compareTo);
+ sb.append('{');
+ boolean first = true;
+ for (String name : names) {
+ if (!first) {
+ sb.append(',');
+ }
+ first = false;
+ writeString(name, sb);
+ sb.append(':');
+ write(node.get(name), sb);
+ }
+ sb.append('}');
+ return;
+ }
+ if (node.isArray()) {
+ sb.append('[');
+ for (int i = 0; i < node.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ write(node.get(i), sb);
+ }
+ sb.append(']');
+ return;
+ }
+ if (node.isNumber()) {
+ sb.append(canonicalNumber(node.decimalValue()));
+ return;
+ }
+ if (node.isBoolean()) {
+ sb.append(node.booleanValue() ? "true" : "false");
+ return;
+ }
+ writeString(node.asText(), sb);
+ }
+
+ /** 规则③:整值与小数同一条十进制通路 */
+ private static String canonicalNumber(BigDecimal raw) {
+ BigDecimal stripped = raw.stripTrailingZeros();
+ // scale<0 时 toPlainString 仍会给 "1E+2" 形态的补零,这里手动摊平
+ if (stripped.scale() < 0) {
+ stripped = stripped.setScale(0);
+ }
+ return stripped.toPlainString();
+ }
+
+ private static void writeString(String value, StringBuilder sb) {
+ sb.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\b':
+ sb.append("\\b");
+ break;
+ case '\f':
+ sb.append("\\f");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ sb.append("\\u00").append(String.format("%02X", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ sb.append('"');
+ }
+
+ private static String sha256Hex(String input) {
+ MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance(HASH_ALGORITHM);
+ } catch (NoSuchAlgorithmException e) {
+ // JDK 保证有 SHA-256;真缺了就是环境坏了,不能降级成"没有哈希"继续投出去
+ throw new IllegalStateException("SHA-256 不可用(JVM 环境异常)", e);
+ }
+ byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder(hash.length * 2);
+ for (byte b : hash) {
+ hex.append(Character.forDigit((b >> 4) & 0xF, 16));
+ hex.append(Character.forDigit(b & 0xF, 16));
+ }
+ return hex.toString();
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerDeliveryService.java b/src/main/java/com/yunhai/formcenter/service/AnswerDeliveryService.java
new file mode 100644
index 0000000..063574f
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerDeliveryService.java
@@ -0,0 +1,801 @@
+package com.yunhai.formcenter.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.entity.AnswerDelivery;
+import com.yunhai.formcenter.model.AnswerDeliveryModels;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReport;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReportView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantSnapshotView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.Snapshot;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotEnvelope;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant;
+import com.yunhai.formcenter.repository.AnswerDeliveryRepository;
+import com.yunhai.formcenter.repository.AnswerRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.core.Message;
+import org.springframework.amqp.core.MessageDeliveryMode;
+import org.springframework.amqp.core.MessageProperties;
+import org.springframework.amqp.rabbit.connection.CorrelationData;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import javax.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 快照投递服务(answer-delivery-v2 §3/§5,TASK_2 B.1/B.3/B.4/B.6 + TASK_3 扩展点)。
+ *
+ * 四件事按顺序各归各位,混一条就不可靠:
+ *
+ * - 入队=答卷事务内的一次写({@link #enqueueInTransaction}):outbox 行与答卷行同生同死,
+ * 进程在提交后、发送前被杀也只是"晚投",不是"丢投"——这是本类与旧
+ * {@code FcEventPublisher}(afterCommit 才落"发"这个动作、丢了由拉取兜底)的根本分工差别,
+ * 任务书明令不许照搬旧形态;
+ * - afterCommit 只唤醒扫描({@link #wakeScan}):扫的是库里的行,不是内存里的闭包;
+ * - 发送前再核授权({@link #publish}):行的目标列只证明"入队那刻"合法,
+ * 撤权后不重放旧 grant(v2 §5),但已交出去的明文副本不承诺远程收回;
+ * - broker 收下≠消费方应用:confirm 只写 {@code broker_accepted_at},
+ * {@code consumer_applied_at} 只能由消费方签名回执写({@link #recordApplied})。
+ *
+ *
+ * 没配 broker 时本类不 no-op 丢事件:行留在 {@code PENDING},启动与每次扫描各记一条日志,
+ * 状态可用 {@code failedPage} / {@code countByState} 观察(B.4"保留待投递并可观察")。
+ */
+@Slf4j
+@Service
+public class AnswerDeliveryService {
+
+ private static final DateTimeFormatter OCCURRED_AT = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+ private static final DateTimeFormatter TS = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+ private static final String HEADER_DELIVERY_ID = "fc-delivery-id";
+ /** 退避序列:base×2^n,封顶 cap(broker 故障时不许热循环) */
+ private static final List SCANNABLE_STATES =
+ Arrays.asList(AnswerDelivery.STATE_PENDING, AnswerDelivery.STATE_FAILED);
+
+ private final AnswerDeliveryRepository deliveryRepository;
+ private final AnswerRepository answerRepository;
+ private final AnswerSnapshotService snapshots;
+ private final FormConsumerAccessService accessService;
+ private final ObjectMapper objectMapper;
+ private final TransactionTemplate transactionTemplate;
+ private final ObjectProvider templates;
+
+ /** 发布方身份(服务端配置;不接受消费方申报,也不从资源 appCode 推) */
+ @Value("${fc.answer.delivery.source-app-key:${spring.application.name:form-center}}")
+ private String sourceAppKey;
+
+ @Value("${spring.rabbitmq.host:}")
+ private String host;
+
+ /** broker 配了但没开 correlated confirm:拿不到"总线收下"的证据,扫描必须拒绝发送(B.4) */
+ @Value("${spring.rabbitmq.publisher-confirm-type:none}")
+ private String confirmType;
+
+ @Value("${fc.answer.delivery.batch-size:50}")
+ private int batchSize;
+
+ @Value("${fc.answer.delivery.max-retries:8}")
+ private int maxRetries;
+
+ @Value("${fc.answer.delivery.backoff-base-seconds:30}")
+ private long backoffBaseSeconds;
+
+ @Value("${fc.answer.delivery.backoff-cap-seconds:3600}")
+ private long backoffCapSeconds;
+
+ private final AtomicBoolean exchangeDeclared = new AtomicBoolean(false);
+ private final AtomicBoolean missingMqLogged = new AtomicBoolean(false);
+
+ /** 扫描专用单线程:串行发送,避免同一行被两个线程各发一次 */
+ private static final ExecutorService SCAN_EXECUTOR = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "fc-answer-delivery-scan");
+ t.setDaemon(true);
+ return t;
+ });
+
+ @Autowired
+ public AnswerDeliveryService(AnswerDeliveryRepository deliveryRepository,
+ AnswerRepository answerRepository,
+ AnswerSnapshotService snapshots,
+ FormConsumerAccessService accessService,
+ ObjectMapper objectMapper,
+ TransactionTemplate transactionTemplate,
+ ObjectProvider templates) {
+ this.deliveryRepository = deliveryRepository;
+ this.answerRepository = answerRepository;
+ this.snapshots = snapshots;
+ this.accessService = accessService;
+ this.objectMapper = objectMapper;
+ this.transactionTemplate = transactionTemplate;
+ this.templates = templates;
+ }
+
+ @PostConstruct
+ public void logState() {
+ if (!hostConfigured()) {
+ log.info("[v2投递] 未配置 RABBITMQ_HOST:outbox 仍照常落库并留在 PENDING(不丢事件,B.4)");
+ return;
+ }
+ if (!"correlated".equalsIgnoreCase(confirmType)) {
+ log.error("[v2投递] 已配置 broker 但 spring.rabbitmq.publisher-confirm-type={} —— "
+ + "拿不到逐条 confirm 就不发送(宁可积压也不按未确认投递),请置 correlated", confirmType);
+ return;
+ }
+ registerCallbacks();
+ log.info("[v2投递] 启用:exchange={},sourceAppKey={},批量={},最大重试={}",
+ AnswerDeliveryModels.EXCHANGE, sourceAppKey, batchSize, maxRetries);
+ }
+
+ public boolean hostConfigured() {
+ return host != null && !host.trim().isEmpty();
+ }
+
+ /** 投递通道是否可用(broker + confirm 双条件;不满足=只入队不发送) */
+ public boolean sendEnabled() {
+ return hostConfigured() && "correlated".equalsIgnoreCase(confirmType)
+ && templates.getIfAvailable() != null;
+ }
+
+ // ==================== 取号(答卷事务内) ====================
+
+ /**
+ * 取下一个快照修订号(行级 CAS,B.1 的并发控制)。
+ *
+ * 调用时机是业务字段改完之后、{@code save} 之前:JPQL 批量更新前 Hibernate 会先
+ * 自动 flush 已改字段(此时行上仍是旧号,CAS 匹配得上),随后这条 UPDATE 把号推到 next
+ * 并拿到行锁;并发对手在同一行上被挡到本事务提交,回来时 {@code WHERE} 不再匹配 → 0 行 → 拒。
+ * 顺序反过来(先改实体字段再 CAS)会造成自我拒绝。
+ */
+ public long nextRevision(Answer answer) {
+ if (answer == null || answer.getId() == null) {
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "取快照修订号需要已落库的答卷行");
+ }
+ long current = answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision();
+ long next = current + 1;
+ int updated = answerRepository.compareAndSetRevision(answer.getId(), current, next);
+ if (updated != 1) {
+ throw new BizException(ErrorCode.CUSTOM,
+ "答卷并发修改冲突(快照修订号 CAS 未命中),本次操作回滚,请重试:" + answer.getUuid());
+ }
+ answer.setAnswerRevision(next);
+ return next;
+ }
+
+ // ==================== 入队(答卷事务内) ====================
+
+ /**
+ * 在调用方事务内为该答卷的当前 revision 建逐订阅投递行。
+ *
+ * 无订阅的简单表单返回空列表——答卷照常提交,不创建任何消费投递记录(B.2 末句)。
+ * 撞 {@code uk_delivery_revision} 说明另一个事务已把同一 revision 投出去了(CAS 没挡住的极端形态),
+ * 整事务回滚是唯一安全处置,不做"改号重试"。
+ */
+ public int enqueueInTransaction(Answer answer, String eventType) {
+ if (answer == null || answer.getUuid() == null || answer.getAnswerRevision() == null) {
+ return 0;
+ }
+ List grants =
+ accessService.listEnabledSubscriptions(answer.getFormUuid(), answer.getSchId());
+ if (grants.isEmpty()) {
+ return 0;
+ }
+ String contentHash = snapshots.contentHash(answer);
+ LocalDateTime occurredAt = answer.getUpdateTime() == null ? LocalDateTime.now() : answer.getUpdateTime();
+ for (SubscriptionGrant grant : grants) {
+ AnswerDelivery row = new AnswerDelivery();
+ row.setEventId(UUID.randomUUID().toString());
+ row.setProtocolVersion(AnswerDeliveryModels.PROTOCOL_VERSION);
+ row.setEventType(eventType);
+ row.setSourceAppKey(sourceAppKey);
+ row.setConsumerAppKey(grant.getConsumerAppKey());
+ row.setConsumerSchoolId(grant.getConsumerSchoolId());
+ row.setGrantVersion(grant.getGrantVersion());
+ row.setFormUuid(answer.getFormUuid());
+ row.setFormVersion(answer.getVersion());
+ row.setAnswerUuid(answer.getUuid());
+ row.setAnswerRevision(answer.getAnswerRevision());
+ row.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()) ? 1 : 0);
+ row.setContentHash(contentHash);
+ row.setOccurredAt(occurredAt);
+ row.setSnapshotJson(writeSnapshot(answer, grant));
+ row.setState(AnswerDelivery.STATE_PENDING);
+ row.setNextRetryAt(occurredAt);
+ try {
+ deliveryRepository.saveAndFlush(row);
+ } catch (DataIntegrityViolationException e) {
+ // CAS 之后仍有同 revision 投递行=另一事务已抢先落定同一份快照。
+ // 抛业务异常而不是继续透传 DIVE:提交链路外层的 DIVE 处理会把"撞幂等键"翻译成
+ // "返回原答卷",那正是这里最不该出现的语义(并发丢更新会被伪装成重放成功)。
+ throw new BizException(ErrorCode.CUSTOM, "并发修改冲突(同一快照修订号已有投递行),本次提交回滚,请重试");
+ }
+ }
+ log.info("[v2投递] 入队 answer={} rev={} event={} 目标={}", answer.getUuid(),
+ answer.getAnswerRevision(), eventType, grants.size());
+ return grants.size();
+ }
+
+ private String writeSnapshot(Answer answer, SubscriptionGrant grant) {
+ Snapshot snapshot = snapshots.forConsumer(answer, grant.getConsumerAppKey(), grant.getConsumerSchoolId());
+ try {
+ return objectMapper.writeValueAsString(snapshot);
+ } catch (Exception e) {
+ throw new IllegalStateException("快照序列化失败(答卷提交必须一起失败,不能留无载荷投递行):"
+ + answer.getUuid(), e);
+ }
+ }
+
+ /**
+ * 入队 + 唤醒(写侧唯一该调的口):{@link #enqueueInTransaction} 必须在调用方事务内,
+ * {@link #wakeScan} 自己识别 afterCommit,调用点不需要知道事务边界在哪。
+ */
+ public void enqueueAndWake(Answer answer, String eventType) {
+ if (enqueueInTransaction(answer, eventType) > 0) {
+ wakeScan();
+ }
+ }
+
+ // ==================== 唤醒扫描(afterCommit 的唯一职责) ====================
+
+ /** 事务提交后唤醒一次扫描;无活动事务(单测/非事务调用)就地异步跑 */
+ public void wakeScan() {
+ if (!sendEnabled()) {
+ return;
+ }
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ SCAN_EXECUTOR.submit(AnswerDeliveryService.this::sendDueBatchQuietly);
+ }
+ });
+ return;
+ }
+ SCAN_EXECUTOR.submit(AnswerDeliveryService.this::sendDueBatchQuietly);
+ }
+
+ /** 定时/唤醒共用:一次批量,异常只记日志(扫描器不许把业务线程带崩) */
+ public int sendDueBatchQuietly() {
+ if (!sendEnabled()) {
+ if (hostConfigured() || !missingMqLogged.getAndSet(true)) {
+ missingMqLogged.set(true);
+ if (!hostConfigured()) {
+ log.info("[v2投递] 无 broker:{} 条 PENDING 保留待发(countByState 可观察)",
+ deliveryRepository.countByState(AnswerDelivery.STATE_PENDING));
+ }
+ }
+ return 0;
+ }
+ try {
+ return sendDueBatch();
+ } catch (Exception e) {
+ log.warn("[v2投递] 扫描异常(不重试到业务线程):{}", e.toString());
+ return 0;
+ }
+ }
+
+ /** 到点批量发送(confirm 异步回写状态,本方法只负责"发出去"与"发不出去就退避") */
+ public int sendDueBatch() {
+ List due = deliveryRepository.findDue(LocalDateTime.now(),
+ PageRequest.of(0, Math.max(1, batchSize)));
+ int sent = 0;
+ for (AnswerDelivery row : due) {
+ if (publish(row)) {
+ sent++;
+ }
+ }
+ return sent;
+ }
+
+ private boolean publish(AnswerDelivery row) {
+ SubscriptionGrant current = findLiveGrant(row);
+ if (current == null || row.getGrantVersion() == null
+ || current.getGrantVersion() != row.getGrantVersion()) {
+ markTerminal(row.getId(), AnswerDelivery.STATE_GRANT_REVOKED,
+ current == null ? "订阅授权已失效(发布前复核)"
+ : "授权纪元已进位(入队 " + row.getGrantVersion() + " → 现 "
+ + current.getGrantVersion() + "),不重放旧 grant");
+ log.warn("[v2投递] 发布前 grant 复核未过:eventId={} consumer={}@{} form={} state={}",
+ row.getEventId(), row.getConsumerAppKey(), row.getConsumerSchoolId(),
+ row.getFormUuid(), AnswerDelivery.STATE_GRANT_REVOKED);
+ return false;
+ }
+ RabbitTemplate template = templates.getIfAvailable();
+ if (template == null) {
+ markFailed(row, "容器内无 RabbitTemplate");
+ return false;
+ }
+ try {
+ declareExchangeOnce(template);
+ byte[] body = objectMapper.writeValueAsString(envelopeOf(row)).getBytes(StandardCharsets.UTF_8);
+ MessageProperties props = new MessageProperties();
+ props.setMessageId(row.getEventId());
+ props.setCorrelationId(String.valueOf(row.getId()));
+ props.setHeader(HEADER_DELIVERY_ID, String.valueOf(row.getId()));
+ props.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
+ props.setContentType(MessageProperties.CONTENT_TYPE_JSON);
+ props.setContentEncoding(StandardCharsets.UTF_8.name());
+ template.send(AnswerDeliveryModels.EXCHANGE, routingKey(row), new Message(body, props),
+ new CorrelationData(String.valueOf(row.getId())));
+ return true;
+ } catch (Exception e) {
+ markFailed(row, e.toString());
+ log.warn("[v2投递] 发送异常,退避重试:eventId={} cause={}", row.getEventId(), e.toString());
+ return false;
+ }
+ }
+
+ /** routing key:显式消费应用 + 租户域(不是资源 appCode,B.3) */
+ public static String routingKey(AnswerDelivery row) {
+ return routingKey(row.getConsumerAppKey(), row.getConsumerSchoolId());
+ }
+
+ public static String routingKey(String consumerAppKey, Long consumerSchoolId) {
+ return "fc.snapshot.v2." + consumerAppKey + "." + consumerSchoolId;
+ }
+
+ private SnapshotEnvelope envelopeOf(AnswerDelivery row) {
+ SnapshotEnvelope envelope = new SnapshotEnvelope();
+ envelope.setProtocolVersion(row.getProtocolVersion());
+ envelope.setEventId(row.getEventId());
+ envelope.setEventType(row.getEventType());
+ envelope.setSourceAppKey(row.getSourceAppKey());
+ envelope.setConsumerAppKey(row.getConsumerAppKey());
+ envelope.setConsumerSchoolId(row.getConsumerSchoolId());
+ envelope.setGrantVersion(row.getGrantVersion());
+ envelope.setFormUuid(row.getFormUuid());
+ envelope.setFormVersion(row.getFormVersion());
+ envelope.setAnswerUuid(row.getAnswerUuid());
+ envelope.setAnswerRevision(row.getAnswerRevision());
+ envelope.setIsDeleted(Integer.valueOf(1).equals(row.getIsDeleted()));
+ envelope.setContentHash(row.getContentHash());
+ envelope.setOccurredAt(row.getOccurredAt() == null ? null
+ : row.getOccurredAt().atZone(java.time.ZoneId.of("Asia/Shanghai")).format(OCCURRED_AT));
+ envelope.setSnapshot(readSnapshot(row.getSnapshotJson()));
+ return envelope;
+ }
+
+ private Snapshot readSnapshot(String json) {
+ try {
+ return objectMapper.readValue(json, Snapshot.class);
+ } catch (Exception e) {
+ throw new IllegalStateException("投递载荷损坏(不可变行不该出现这种数据)", e);
+ }
+ }
+
+ /** 消费方在该答卷上的当前生效订阅(只走 TASK_1 的判据,本类不拼第二套 ACL) */
+ private SubscriptionGrant findLiveGrant(AnswerDelivery row) {
+ for (SubscriptionGrant grant : accessService.listEnabledSubscriptions(row.getFormUuid(),
+ row.getConsumerSchoolId())) {
+ if (row.getConsumerAppKey().equals(grant.getConsumerAppKey())) {
+ return grant;
+ }
+ }
+ return null;
+ }
+
+ private void declareExchangeOnce(RabbitTemplate template) {
+ if (!exchangeDeclared.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ template.execute(channel -> {
+ channel.exchangeDeclare(AnswerDeliveryModels.EXCHANGE, "topic", true);
+ return null;
+ });
+ } catch (Exception e) {
+ exchangeDeclared.set(false);
+ throw e;
+ }
+ }
+
+ // ==================== confirm / return 回写 ====================
+
+ private void registerCallbacks() {
+ RabbitTemplate template = templates.getIfAvailable();
+ if (template == null) {
+ log.warn("[v2投递] 容器内没有 RabbitTemplate,无法注册 confirm/return 回调");
+ return;
+ }
+ template.setConfirmCallback((correlationData, ack, cause) -> {
+ if (correlationData == null || correlationData.getId() == null) {
+ return; // v1 广播通道不带我们的相关键
+ }
+ try {
+ onBrokerConfirm(Long.valueOf(correlationData.getId()), ack, cause);
+ } catch (Exception e) {
+ log.warn("[v2投递] confirm 回写失败 id={}:{}", correlationData.getId(), e.toString());
+ }
+ });
+ template.setReturnsCallback(returned -> {
+ Long id = deliveryIdOf(returned.getMessage());
+ if (id == null) {
+ return;
+ }
+ onUnroutable(id, returned.getExchange() + "/" + returned.getRoutingKey()
+ + " reply=" + returned.getReplyCode() + " " + returned.getReplyText());
+ });
+ }
+
+ private static Long deliveryIdOf(Message message) {
+ if (message == null || message.getMessageProperties() == null) {
+ return null;
+ }
+ Object header = message.getMessageProperties().getHeaders().get(HEADER_DELIVERY_ID);
+ if (header == null) {
+ return null;
+ }
+ try {
+ return Long.valueOf(String.valueOf(header));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /** confirm 只落 brokerAccepted;nack 走退避(B.4"confirm 只记 brokerAccepted") */
+ void onBrokerConfirm(Long deliveryId, boolean ack, String cause) {
+ transactionTemplate.executeWithoutResult(tx -> {
+ AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null);
+ if (row == null) {
+ return;
+ }
+ if (AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState())) {
+ return; // 重复 confirm(broker 重投同一相关键)幂等吸收
+ }
+ if (ack) {
+ row.setState(AnswerDelivery.STATE_BROKER_ACCEPTED);
+ row.setBrokerAcceptedAt(LocalDateTime.now());
+ row.setLastError(null);
+ } else {
+ bumpRetry(row, "broker nack: " + cause);
+ }
+ deliveryRepository.save(row);
+ });
+ }
+
+ /** mandatory 不可路由=目标私有队列没绑上(部署契约缺隔离配置),按失败退避而不是静默丢弃 */
+ void onUnroutable(Long deliveryId, String detail) {
+ transactionTemplate.executeWithoutResult(tx -> {
+ AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null);
+ if (row == null || AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState())) {
+ return;
+ }
+ markFailed(row, "不可路由(mandatory return):" + detail);
+ deliveryRepository.save(row);
+ });
+ log.warn("[v2投递] 消息不可路由:id={} {}", deliveryId, detail);
+ }
+
+ private void markFailed(AnswerDelivery row, String error) {
+ transactionTemplate.executeWithoutResult(tx -> {
+ AnswerDelivery fresh = deliveryRepository.findById(row.getId()).orElse(null);
+ if (fresh == null) {
+ return;
+ }
+ bumpRetry(fresh, error);
+ deliveryRepository.save(fresh);
+ });
+ }
+
+ private void markTerminal(Long id, String state, String error) {
+ transactionTemplate.executeWithoutResult(tx -> {
+ AnswerDelivery row = deliveryRepository.findById(id).orElse(null);
+ if (row == null) {
+ return;
+ }
+ row.setState(state);
+ row.setLastError(truncate(error));
+ deliveryRepository.save(row);
+ });
+ }
+
+ /** 退避计数在投递行上,不在快照里:重试次数属于投递元数据,进哈希就会让同一 revision 多 hash */
+ private void bumpRetry(AnswerDelivery row, String error) {
+ int retries = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
+ row.setRetryCount(retries);
+ row.setLastError(truncate(error));
+ if (retries >= Math.max(1, maxRetries)) {
+ row.setState(AnswerDelivery.STATE_DEAD);
+ row.setNextRetryAt(null);
+ log.error("[v2投递] 有界重试耗尽→死信待处置:eventId={} consumer={} retries={} last={}",
+ row.getEventId(), row.getConsumerAppKey(), retries, error);
+ return;
+ }
+ row.setState(AnswerDelivery.STATE_FAILED);
+ row.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds(retries)));
+ }
+
+ private long backoffSeconds(int retries) {
+ long base = Math.max(1L, backoffBaseSeconds);
+ long cap = Math.max(base, backoffCapSeconds);
+ long delay = base;
+ for (int i = 1; i < retries && delay < cap; i++) {
+ delay = Math.min(cap, delay * 2);
+ }
+ return delay;
+ }
+
+ // ==================== 消费方回执(consumerApplied 唯一写入点) ====================
+
+ /**
+ * 幂等记录消费方应用结果:按 {@code eventId + consumerAppKey + consumerSchoolId + revision}
+ * 逐字复核,任一不符即拒(不许用"MQ 已确认"代替业务成功,B.6)。
+ */
+ @Transactional
+ public AppliedReportView recordApplied(AppliedReport report) {
+ if (report == null || isBlank(report.getEventId())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "eventId 必填");
+ }
+ AnswerDelivery row = deliveryRepository.findByEventId(report.getEventId().trim()).orElse(null);
+ if (row == null || !equalsText(row.getConsumerAppKey(), report.getConsumerAppKey())
+ || !equalsLong(row.getConsumerSchoolId(), report.getConsumerSchoolId())
+ || !equalsLong(row.getAnswerRevision(), report.getAnswerRevision())
+ || !equalsText(row.getAnswerUuid(), report.getAnswerUuid())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "回执与投递行不匹配(eventId/消费方/revision 任一不符即拒)");
+ }
+ String outcome = normalizeOutcome(report.getOutcome());
+ AppliedReportView view = new AppliedReportView();
+ view.setEventId(row.getEventId());
+ view.setBrokerAcceptedAt(row.getBrokerAcceptedAt() == null ? null : TS.format(row.getBrokerAcceptedAt()));
+ if (row.getConsumerAppliedAt() != null) {
+ // 已落定:同结论=幂等重复;不同结论=冲突,保留第一次(不覆盖,与消费端 B.5 同口径)
+ view.setState(row.getConsumerOutcome());
+ view.setConsumerAppliedAt(TS.format(row.getConsumerAppliedAt()));
+ view.setDuplicate(outcome.equals(row.getConsumerOutcome()));
+ if (!outcome.equals(row.getConsumerOutcome())) {
+ row.setLastError(truncate("回执结论冲突:已有 " + row.getConsumerOutcome() + ",又收到 " + outcome));
+ deliveryRepository.save(row);
+ log.warn("[v2投递] 同一 eventId 收到不同结论(保留首次):eventId={} has={} incoming={}",
+ row.getEventId(), row.getConsumerOutcome(), outcome);
+ }
+ return view;
+ }
+ if (AnswerDelivery.OUTCOME_APPLIED.equals(outcome)
+ && !equalsText(row.getContentHash(), report.getContentHash())) {
+ // 自称 APPLIED 但 hash 对不上信封:按 CONFLICT 记,不把"声称成功"当成成功
+ outcome = AnswerDelivery.OUTCOME_CONFLICT;
+ }
+ row.setConsumerOutcome(outcome);
+ row.setConsumerAppliedAt(LocalDateTime.now());
+ if (report.getLocalRef() != null && !report.getLocalRef().trim().isEmpty()) {
+ row.setLastError(truncate("localRef=" + report.getLocalRef().trim()));
+ }
+ deliveryRepository.save(row);
+ view.setState(outcome);
+ view.setConsumerAppliedAt(TS.format(row.getConsumerAppliedAt()));
+ view.setDuplicate(Boolean.FALSE);
+ return view;
+ }
+
+ private static String normalizeOutcome(String raw) {
+ String value = raw == null ? "" : raw.trim().toUpperCase();
+ switch (value) {
+ case AnswerDelivery.OUTCOME_APPLIED:
+ case AnswerDelivery.OUTCOME_IGNORED:
+ case AnswerDelivery.OUTCOME_CONFLICT:
+ return value;
+ default:
+ throw new BizException(ErrorCode.PARAM_ERROR, "outcome 只能是 APPLIED/IGNORED/CONFLICT:" + raw);
+ }
+ }
+
+ // ==================== TASK_3 扩展点(只调用不编辑,签名即契约) ====================
+
+ /**
+ * 授权下的重投(对账修复入口)。状态码是稳定值,TASK_3 按码分支:
+ *
+ * - {@code NO_DELIVERY_ROW}:该消费方对这份答卷从来没有投递行(从未授权或从未订阅),
+ * 不新建——历史 revision 的明文快照只存在于已落库的不可变行里,无行即不可重建;
+ * - {@code REVISION_MISMATCH}:期望 revision 不在已有行集内(同上,附当前最大号供运维判方向);
+ * - {@code GRANT_REVOKED}:当前订阅授权不生效或纪元已进位,绝不重放旧 grant(v2 §5);
+ * - {@code ALREADY_DELIVERED}:消费方已回执 APPLIED,重投只会被对端幂等吸收,不做无谓发送;
+ * - {@code QUEUED}:行已回到 PENDING,等下一次扫描。
+ *
+ *
+ * {@code operationId} 是调用方的操作幂等键:同一 operationId 重复调用不再刷新退避,
+ * 直接回 {@code QUEUED}(同一次修复动作不因重试而变成多次重投)。
+ */
+ @Transactional
+ public DeliveryStateView replayAuthorized(ConsumerPrincipal caller, String answerUuid,
+ Long expectedRevision, String operationId) {
+ if (caller == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
+ }
+ if (isBlank(answerUuid) || expectedRevision == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "answerUuid 与 expectedRevision 必填");
+ }
+ DeliveryStateView view = new DeliveryStateView();
+ view.setAnswerRevision(expectedRevision);
+ List rows = deliveryRepository
+ .findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(answerUuid.trim(), caller.getAppKey());
+ if (rows.isEmpty()) {
+ view.setState(STATE_NO_DELIVERY_ROW);
+ view.setMessage("该消费方对这份答卷没有投递行(从未订阅或从未授权),历史快照不可重建");
+ return view;
+ }
+ AnswerDelivery target = null;
+ long maxRevision = 0L;
+ for (AnswerDelivery row : rows) {
+ maxRevision = Math.max(maxRevision, row.getAnswerRevision() == null ? 0L : row.getAnswerRevision());
+ if (expectedRevision.equals(row.getAnswerRevision())) {
+ target = row;
+ }
+ }
+ if (target == null) {
+ view.setState(STATE_REVISION_MISMATCH);
+ view.setMessage("期望 revision=" + expectedRevision + " 无投递行(现有最大 revision=" + maxRevision + ")");
+ return view;
+ }
+ view.setEventId(target.getEventId());
+ if (!grantStillLive(target)) {
+ view.setState(STATE_GRANT_REVOKED);
+ view.setMessage("订阅授权当前不生效或纪元已进位,不重放旧 grant");
+ return view;
+ }
+ if (target.getConsumerAppliedAt() != null
+ && AnswerDelivery.OUTCOME_APPLIED.equals(target.getConsumerOutcome())) {
+ view.setState(STATE_ALREADY_DELIVERED);
+ view.setMessage("消费方已回执 APPLIED,无需重投");
+ return view;
+ }
+ if (operationId != null && operationId.equals(target.getLastReplayOperationId())
+ && SCANNABLE_STATES.contains(target.getState())) {
+ view.setState(STATE_QUEUED);
+ view.setMessage("同 operationId 已在队列中(幂等吸收,不刷新退避)");
+ return view;
+ }
+ target.setState(AnswerDelivery.STATE_PENDING);
+ target.setNextRetryAt(LocalDateTime.now());
+ target.setLastReplayOperationId(operationId);
+ deliveryRepository.save(target);
+ view.setState(STATE_QUEUED);
+ view.setMessage("已回到 PENDING,等待扫描发送");
+ wakeScan();
+ return view;
+ }
+
+ /** 当前生效订阅复核(TASK_1 唯一判据;本类不拼第二套 ACL) */
+ private boolean grantStillLive(AnswerDelivery row) {
+ return findLiveGrant(row) != null;
+ }
+
+ // ==================== TASK_3 稳定状态码 ====================
+
+ public static final String STATE_NO_DELIVERY_ROW = "NO_DELIVERY_ROW";
+ public static final String STATE_REVISION_MISMATCH = "REVISION_MISMATCH";
+ public static final String STATE_GRANT_REVOKED = "GRANT_REVOKED";
+ public static final String STATE_ALREADY_DELIVERED = "ALREADY_DELIVERED";
+ public static final String STATE_QUEUED = "QUEUED";
+
+ /** 失败/死信分页(运维与 TASK_3 对账) */
+ public FailedDeliveryPage failedPage(ConsumerPrincipal caller, Long consumerSchoolId,
+ Integer page, Integer size) {
+ if (caller == null || consumerSchoolId == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份或租户域");
+ }
+ int pageIndex = page == null || page < 1 ? 0 : page - 1;
+ int pageSize = size == null || size < 1 ? 20 : Math.min(size, 100);
+ Page result = deliveryRepository.findFailedForConsumer(caller.getAppKey(),
+ consumerSchoolId, PageRequest.of(pageIndex, pageSize));
+ FailedDeliveryPage out = new FailedDeliveryPage();
+ out.setTotal(result.getTotalElements());
+ for (AnswerDelivery row : result.getContent()) {
+ FailedDeliveryView item = new FailedDeliveryView();
+ item.setEventId(row.getEventId());
+ item.setAnswerUuid(row.getAnswerUuid());
+ item.setAnswerRevision(row.getAnswerRevision());
+ item.setConsumerAppKey(row.getConsumerAppKey());
+ item.setConsumerSchoolId(row.getConsumerSchoolId());
+ item.setState(row.getState());
+ item.setRetryCount(row.getRetryCount());
+ item.setLastError(row.getLastError());
+ item.setNextRetryAt(row.getNextRetryAt() == null ? null : TS.format(row.getNextRetryAt()));
+ item.setBrokerAcceptedAt(row.getBrokerAcceptedAt() == null ? null : TS.format(row.getBrokerAcceptedAt()));
+ item.setConsumerAppliedAt(row.getConsumerAppliedAt() == null ? null : TS.format(row.getConsumerAppliedAt()));
+ out.getRows().add(item);
+ }
+ return out;
+ }
+
+ // ==================== 轻量授权核验(B.6 / 撤权边界) ====================
+
+ /**
+ * 按 formUuid 批量回授权快照:不回查任何答案值文档,只回答"这个消费方在这所学校
+ * 对这张表当前还能不能收订阅"。判据完全复用 TASK_1 的 {@code requireSubscribe}——
+ * 拒绝(含跨校、无 grant、已撤权)统一落成 {@code subscribeAllowed=false},
+ * 消费端据此停止写入并把缓存里的旧纪元作废。
+ *
+ * 单次上限 100 张:这条接口是给消费端逐批核验用的(契约 v2 §5 撤权边界),
+ * 不是给每条事件回查用的——后者会把授权判定放大成热点。
+ */
+ public List grantSnapshot(ConsumerPrincipal caller, Long consumerSchoolId,
+ List formUuids) {
+ if (caller == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
+ }
+ if (consumerSchoolId == null || consumerSchoolId <= 0) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)");
+ }
+ List out = new ArrayList<>();
+ if (formUuids == null || formUuids.isEmpty()) {
+ return out;
+ }
+ String verifiedAt = TS.format(LocalDateTime.now());
+ int limit = Math.min(formUuids.size(), 100);
+ for (int i = 0; i < limit; i++) {
+ String formUuid = formUuids.get(i);
+ if (isBlank(formUuid)) {
+ continue;
+ }
+ GrantSnapshotView view = new GrantSnapshotView();
+ view.setFormUuid(formUuid.trim());
+ view.setConsumerSchoolId(consumerSchoolId);
+ view.setConsumerAppKey(caller.getAppKey());
+ view.setVerifiedAt(verifiedAt);
+ try {
+ SubscriptionGrant grant = accessService.requireSubscribe(caller, consumerSchoolId,
+ formUuid.trim());
+ view.setSubscribeAllowed(Boolean.TRUE);
+ view.setGrantVersion(grant.getGrantVersion());
+ } catch (BizException denied) {
+ // 跨校/无 grant/已撤权在这里本来就是同一句拒绝,对外也只给"不收"这一个事实
+ view.setSubscribeAllowed(Boolean.FALSE);
+ view.setGrantVersion(0);
+ }
+ out.add(view);
+ }
+ return out;
+ }
+
+ // ==================== 小工具 ====================
+
+ private static String truncate(String value) {
+ if (value == null) {
+ return null;
+ }
+ return value.length() <= 500 ? value : value.substring(0, 500);
+ }
+
+ private static boolean isBlank(String raw) {
+ return raw == null || raw.trim().isEmpty();
+ }
+
+ private static String trim(String raw) {
+ return raw == null ? null : raw.trim();
+ }
+
+ private static boolean equalsText(String left, String right) {
+ String a = trim(left);
+ String b = trim(right);
+ return a == null ? b == null : a.equals(b);
+ }
+
+ private static boolean equalsLong(Long left, Long right) {
+ return left == null ? right == null : left.equals(right);
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
index d25ad83..897da6b 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
@@ -11,6 +11,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.model.MyModels;
+import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
@@ -67,6 +68,8 @@ public class AnswerReadService {
private final FormFileRepository fileRepository;
private final AnswerOpLogRepository opLogRepository;
private final FcEventPublisher events;
+ /** v2 快照投递:取号与 outbox 入队都发生在本类的事务内(TASK_2 B.1) */
+ private final AnswerDeliveryService deliveries;
// ==================== 规格构造(四路径共用) ====================
@@ -316,6 +319,8 @@ public class AnswerReadService {
throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷");
}
answer.setIsDeleted(1);
+ // 软删改变对外快照(墓碑也是变化):同事务取号 + 入队,B.1 明令不许漏这条
+ deliveries.nextRevision(answer);
answerRepository.save(answer);
// 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填
indexService.clear(answer.getId());
@@ -325,6 +330,7 @@ public class AnswerReadService {
opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)");
// 事件:tombstoned(提交人侧软删与管理面各自实体 save,共用发布器这一个出口,§6.2)
events.answerTombstoned(answer);
+ deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_TOMBSTONED);
log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId());
}
@@ -364,6 +370,7 @@ public class AnswerReadService {
dictCode -> dictService.allowedValues(answer.getSchId(), dictCode));
assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson());
answer.setValuesJson(valuesJson);
+ deliveries.nextRevision(answer);
answerRepository.save(answer);
// 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变
indexService.rebuild(answer, pinned.versionRow().getSchemaJson());
@@ -372,6 +379,7 @@ public class AnswerReadService {
: "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim());
// 事件:updated(与管理面编辑同一点位;能走到这里说明该表未开冻结,V61 已在上面拦过)
events.answerUpdated(answer);
+ deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(),
answer.getVersion());
return toRow(answer, form);
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java
new file mode 100644
index 0000000..19c1d68
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java
@@ -0,0 +1,121 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties;
+import com.yunhai.formcenter.model.FormSyncModels.InstructionAck;
+import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction;
+import com.yunhai.platform.openapi.core.TargetSpec;
+import com.yunhai.platform.openapi.core.YschOpenCall;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+
+/**
+ * 对账指令派发客户端(TASK_3 改动点 3/4)。
+ *
+ * 出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入,
+ * 本应用身份取 {@code yh.app-auth},密钥不进代码也不进日志。
+ * 目标地址只有一个来源——{@code fc.answer.reconcile.targets} 里按已验签 consumerAppKey
+ * 登记的后台服务基址;请求体里、浏览器里出现的任何 URL 都不参与寻址(任务书改动点 3 末句)。
+ *
+ * 失败语义:本类不抛异常。派发失败对上层只是一个稳定错误码,
+ * 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。
+ */
+@Slf4j
+@Component
+public class AnswerReconcileDispatchClient {
+
+ private final YschOpenCall openCall;
+ private final AnswerReconcileProperties properties;
+
+ public AnswerReconcileDispatchClient(YschOpenCall openCall, AnswerReconcileProperties properties) {
+ this.openCall = openCall;
+ this.properties = properties;
+ }
+
+ /** 出站能力是否可用(未注入 SDK 或未登记任何后台地址=不可派发,任务留在 QUEUED 不丢) */
+ public boolean enabled() {
+ return openCall != null && properties != null && !properties.getTargets().isEmpty();
+ }
+
+ /** 派发结果的本地结论:2xx 只说明对端收下了指令,绝不映射成任务终态 */
+ @Getter
+ @RequiredArgsConstructor
+ public static class DispatchOutcome {
+ private final boolean accepted;
+ private final String errorCode;
+ private final String message;
+ private final Integer attempt;
+ private final String state;
+ private final boolean duplicate;
+
+ /** 统一构造口(服务层与规则单测都从这里造结论,不再留多个私有工厂) */
+ public static DispatchOutcome of(boolean accepted, String errorCode, String message, Integer attempt,
+ String state, boolean duplicate) {
+ return new DispatchOutcome(accepted, errorCode, message, attempt, state, duplicate);
+ }
+ }
+
+ /** 派发一条对账指令(同 taskId 重复派发由对端按 taskId 幂等复用,这里不另建任务) */
+ public DispatchOutcome dispatch(ReconcileInstruction instruction) {
+ if (instruction == null || instruction.getConsumerAppKey() == null) {
+ return DispatchOutcome.of(false, AnswerReconcileService.ERR_BAD_INSTRUCTION,
+ "指令或消费方身份缺失", null, null, false);
+ }
+ if (!enabled()) {
+ return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED,
+ "出站签名客户端不可用或 fc.answer.reconcile.targets 未登记任何后台地址",
+ instruction.getAttempt(), null, false);
+ }
+ TargetSpec spec = resolveTarget(instruction.getConsumerAppKey());
+ if (spec == null) {
+ return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED,
+ "消费方 " + instruction.getConsumerAppKey() + " 没有登记后台服务地址,不接浏览器 URL",
+ instruction.getAttempt(), null, false);
+ }
+ try {
+ InstructionAck ack = openCall.post(spec, properties.getDispatchPath(),
+ java.util.Collections.emptyMap(), instruction, InstructionAck.class);
+ if (ack == null || ack.getTaskId() == null || !ack.getTaskId().equals(instruction.getTaskId())) {
+ // 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 2xx)
+ return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED,
+ "对端应答的 taskId 与指令不符", instruction.getAttempt(), null, false);
+ }
+ if (Boolean.FALSE.equals(ack.getAccepted())) {
+ return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED,
+ "对端拒收指令:" + ack.getMessage(), ack.getAttempt(), ack.getState(),
+ Boolean.TRUE.equals(ack.getDuplicate()));
+ }
+ return DispatchOutcome.of(true, null, ack.getMessage(), ack.getAttempt(), ack.getState(),
+ Boolean.TRUE.equals(ack.getDuplicate()));
+ } catch (Exception e) {
+ log.warn("[对账] 派发失败 task={} 第 {} 次:{}", instruction.getTaskId(),
+ instruction.getAttempt(), e.toString());
+ return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, brief(e),
+ instruction.getAttempt(), null, false);
+ }
+ }
+
+ /** 登记的后台基址 → host + context-path(与 CMS/流程中心客户端同一拆解口径) */
+ private TargetSpec resolveTarget(String consumerAppKey) {
+ String raw = properties.getTargets().get(consumerAppKey.trim());
+ if (raw == null || raw.trim().isEmpty()) {
+ return null;
+ }
+ URI uri = URI.create(raw.trim());
+ if (uri.getScheme() == null || uri.getRawAuthority() == null) {
+ log.warn("[对账] 登记的后台地址不是绝对 URL,忽略:appKey={}", consumerAppKey);
+ return null;
+ }
+ String host = uri.getScheme() + "://" + uri.getRawAuthority();
+ String path = uri.getRawPath() == null ? "" : uri.getRawPath();
+ return TargetSpec.of(host, path);
+ }
+
+ private String brief(Exception e) {
+ String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+ return message.length() > 300 ? message.substring(0, 300) : message;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileScheduler.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileScheduler.java
new file mode 100644
index 0000000..30ddb7c
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileScheduler.java
@@ -0,0 +1,52 @@
+package com.yunhai.formcenter.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 对账建任务的日调度(TASK_3 改动点 1)。
+ *
+ * 拟配置时点=Asia/Shanghai 每日 02:00,默认停用:开关在
+ * {@code fc.answer.reconcile.schedule-enabled},没开时这个方法一行任务都不建
+ * (与 {@code FlowReconcileJob} 的"开关 + 能力闸"同形,但这里没有它的业务筛选——
+ * 答卷对账扫的是订阅与清单,不是流程状态 10)。
+ *
+ * 闹钟本身不判授权:{@code createScheduledTasks} 只按 TASK_1 的生效订阅建任务,
+ * 未授权订阅一行都不产生。调度与派发是两个开关——建了任务不代表会发出去。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AnswerReconcileScheduler {
+
+ private final AnswerReconcileService reconcileService;
+
+ @Value("${fc.answer.reconcile.schedule-zone:Asia/Shanghai}")
+ private String zone = "Asia/Shanghai";
+
+ /** 营业日=调度触发当天的日期(进入幂等键,同一天重复触发不会叠任务) */
+ @Scheduled(cron = "${fc.answer.reconcile.schedule-cron:0 0 2 * * ?}",
+ zone = "${fc.answer.reconcile.schedule-zone:Asia/Shanghai}")
+ public void createDailyTasks() {
+ if (!reconcileService.properties().isScheduleEnabled()) {
+ return; // 默认停用:未显式开闸就不产生任何对账任务
+ }
+ LocalDate businessDate = LocalDate.now(zone());
+ reconcileService.createScheduledTasks(businessDate);
+ }
+
+ private ZoneId zone() {
+ try {
+ return ZoneId.of(zone == null || zone.trim().isEmpty() ? "Asia/Shanghai" : zone.trim());
+ } catch (Exception e) {
+ log.warn("[对账] 调度时区配置不可用,按 Asia/Shanghai 取营业日:{}", zone);
+ return ZoneId.of("Asia/Shanghai");
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java
new file mode 100644
index 0000000..0ec37e8
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java
@@ -0,0 +1,1262 @@
+package com.yunhai.formcenter.service;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties;
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.entity.AnswerReconcileManifestRow;
+import com.yunhai.formcenter.entity.AnswerReconcileResult;
+import com.yunhai.formcenter.entity.AnswerReconcileTask;
+import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.entity.FormConsumerGrant;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotEnvelope;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotView;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.model.FormSyncModels;
+import com.yunhai.formcenter.model.FormSyncModels.AbsenceProof;
+import com.yunhai.formcenter.model.FormSyncModels.ManifestPageView;
+import com.yunhai.formcenter.model.FormSyncModels.ManifestRowView;
+import com.yunhai.formcenter.model.FormSyncModels.ReconcileError;
+import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction;
+import com.yunhai.formcenter.model.FormSyncModels.ResultAck;
+import com.yunhai.formcenter.model.FormSyncModels.ResultReport;
+import com.yunhai.formcenter.model.FormSyncModels.ResultView;
+import com.yunhai.formcenter.model.FormSyncModels.TaskAnswerReply;
+import com.yunhai.formcenter.model.FormSyncModels.TaskCreateRequest;
+import com.yunhai.formcenter.model.FormSyncModels.TaskView;
+import com.yunhai.formcenter.repository.AnswerReconcileManifestRepository;
+import com.yunhai.formcenter.repository.AnswerReconcileReadRepository;
+import com.yunhai.formcenter.repository.AnswerReconcileResultRepository;
+import com.yunhai.formcenter.repository.AnswerReconcileTaskRepository;
+import com.yunhai.formcenter.repository.AnswerRepository;
+import com.yunhai.formcenter.repository.FormConsumerGrantRepository;
+import com.yunhai.formcenter.repository.FormRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.utils.model.BoaUser;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * 差异对账的中心侧门面(TASK_3,answer-reconcile-v1 全篇)。
+ *
+ * 四类职责,边界各不越:
+ *
+ * - 建任务:只认 TASK_1 的生效订阅({@code enabled=1 and subscribeAllowed=1 and readAllowed=1}),
+ * 按 {@code (日期, 消费方, 消费租户域, 表单)} 唯一键落一行任务;资源超限落一行 FAILED 任务留下拒绝原因,
+ * 绝不在请求线程里无界载表。
+ * - 物化不可变清单:一个一致读事务内把授权范围内的
+ * {@code (answerUuid, revision, hash, isDeleted)} 全部摘要写进
+ * {@code answer_reconcile_manifest_row},此后分页只读这张表(§3)。
+ * - 答复消费端:清单分页 / 当前权威快照信封 / 缺席证明 / 结果受理。四次答复每一次都重新
+ * 验签身份、核对任务坐标并复核最新 grant——不因"建任务时有权"永久放行(改动点 3)。
+ * - 派发:把指令交给已登记的后台服务地址;2xx 只写 {@code DISPATCHED},
+ * 终态只能由 result 回报推进(改动点 4)。
+ *
+ *
+ * 与 TASK_2 的关系:本类只调用 {@code AnswerSnapshotService}/{@code AnswerDeliveryService}/
+ * {@code FormConsumerAccessService} 这些已交付的签名入口,不改它们、不在它们里面加快捷查询,
+ * 也不复制任何哈希规范化实现(哈希一律经 {@code AnswerSnapshotService#contentHash})。
+ */
+@Slf4j
+@Service
+public class AnswerReconcileService {
+
+ /** 稳定错误码(与实体同一集合;本类新增的 BAD_INSTRUCTION/ACTIVE_TASK_EXISTS 也在这里) */
+ public static final String ERR_BAD_INSTRUCTION = "BAD_INSTRUCTION";
+ public static final String ERR_TARGET_NOT_REGISTERED = AnswerReconcileTask.ERR_TARGET_NOT_REGISTERED;
+ public static final String ERR_DISPATCH_FAILED = AnswerReconcileTask.ERR_DISPATCH_FAILED;
+ public static final String ERR_LEASE_EXPIRED = AnswerReconcileTask.ERR_LEASE_EXPIRED;
+ public static final String ERR_GRANT_REVOKED = AnswerReconcileTask.ERR_GRANT_REVOKED;
+ public static final String ERR_CANCELLED = AnswerReconcileTask.ERR_CANCELLED;
+ public static final String ERR_TASK_NOT_FOUND = AnswerReconcileTask.ERR_TASK_NOT_FOUND;
+ public static final String ERR_MANIFEST_INCOMPLETE = AnswerReconcileTask.ERR_MANIFEST_INCOMPLETE;
+ public static final String ERR_RESOURCE_LIMIT = AnswerReconcileTask.ERR_RESOURCE_LIMIT;
+ public static final String ERR_SCOPE_MISMATCH = AnswerReconcileTask.ERR_SCOPE_MISMATCH;
+ public static final String ERR_ACTIVE_TASK_EXISTS = "ACTIVE_TASK_EXISTS";
+ public static final String ERR_ANSWER_NOT_IN_SCOPE = "ANSWER_NOT_IN_SCOPE";
+ public static final String ERR_EXPECTED_REVISION_REQUIRED = "EXPECTED_REVISION_REQUIRED";
+
+ private static final String PRESENCE_PRESENT = FormSyncModels.PRESENCE_PRESENT;
+ private static final String PRESENCE_ABSENT = FormSyncModels.PRESENCE_ABSENT;
+ private static final String PRESENCE_UNKNOWN = FormSyncModels.PRESENCE_UNKNOWN;
+
+ private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+ private static final int ANSWER_UUID_LEN = 32;
+ private static final int PROOF_ID_MAX = 128;
+ private static final int EVENT_ID_MAX = 36;
+ private static final int ERROR_DETAIL_MAX = 300;
+
+ private final AnswerReconcileTaskRepository taskRepository;
+ private final AnswerReconcileManifestRepository manifestRepository;
+ private final AnswerReconcileResultRepository resultRepository;
+ private final AnswerReconcileReadRepository readRepository;
+ private final FormConsumerGrantRepository grantRepository;
+ private final FormRepository formRepository;
+ private final AnswerRepository answerRepository;
+ private final AnswerSnapshotService snapshotService;
+ private final FormConsumerAccessService accessService;
+ private final AnswerDeliveryService deliveryService;
+ private final ConsumerPrincipalResolver callerResolver;
+ private final AnswerReconcileDispatchClient dispatchClient;
+ private final AnswerReconcileProperties properties;
+ private final TransactionTemplate transactionTemplate;
+ private final ObjectMapper objectMapper;
+ /** 信封 sourceAppKey 的兜底身份(本应用签名身份);显式配置优先 */
+ private final String platformAppKey;
+
+ public AnswerReconcileService(AnswerReconcileTaskRepository taskRepository,
+ AnswerReconcileManifestRepository manifestRepository,
+ AnswerReconcileResultRepository resultRepository,
+ AnswerReconcileReadRepository readRepository,
+ FormConsumerGrantRepository grantRepository,
+ FormRepository formRepository,
+ AnswerRepository answerRepository,
+ AnswerSnapshotService snapshotService,
+ FormConsumerAccessService accessService,
+ AnswerDeliveryService deliveryService,
+ ConsumerPrincipalResolver callerResolver,
+ AnswerReconcileDispatchClient dispatchClient,
+ AnswerReconcileProperties properties,
+ TransactionTemplate transactionTemplate,
+ ObjectMapper objectMapper,
+ @Value("${yh.app-auth.app-key:}") String platformAppKey) {
+ this.taskRepository = taskRepository;
+ this.manifestRepository = manifestRepository;
+ this.resultRepository = resultRepository;
+ this.readRepository = readRepository;
+ this.grantRepository = grantRepository;
+ this.formRepository = formRepository;
+ this.answerRepository = answerRepository;
+ this.snapshotService = snapshotService;
+ this.accessService = accessService;
+ this.deliveryService = deliveryService;
+ this.callerResolver = callerResolver;
+ this.dispatchClient = dispatchClient;
+ this.properties = properties;
+ this.transactionTemplate = transactionTemplate;
+ this.objectMapper = objectMapper;
+ this.platformAppKey = platformAppKey;
+ }
+
+ public AnswerReconcileProperties properties() {
+ return properties;
+ }
+
+ // ==================== 1. 建任务 ====================
+
+ /**
+ * 人工建任务(管理面)。三种范围共用一条入口:单答卷 / 单表单 / 该消费方全部生效订阅。
+ *
+ * 返回的是已落库的任务行:资源超限或范围不符时返回的是 {@code state=FAILED} 的留痕行,
+ * 而不是抛异常把原因吞掉——"拒绝建任务并留下拒绝原因"要能在管理面查到。
+ */
+ public List createManualTasks(BoaUser user, Long domain, TaskCreateRequest request) {
+ if (request == null || isBlank(request.getScope())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "scope 必填(ANSWER / FORM / ALL)");
+ }
+ String scope = request.getScope().trim().toUpperCase();
+ String operator = operatorOf(user);
+ if ("ANSWER".equals(scope)) {
+ return Collections.singletonList(createForAnswer(domain, operator, request));
+ }
+ if ("FORM".equals(scope)) {
+ return Collections.singletonList(createForForm(domain, operator,
+ request.getConsumerAppKey(), request.getConsumerSchoolId(), request.getFormUuid(),
+ AnswerReconcileTask.TRIGGER_MANUAL, manualKey(request,
+ request.getConsumerAppKey(), request.getConsumerSchoolId(), request.getFormUuid())));
+ }
+ if ("ALL".equals(scope)) {
+ return createForAllSubscriptions(domain, operator, request);
+ }
+ throw new BizException(ErrorCode.PARAM_ERROR, "scope 只能是 ANSWER / FORM / ALL:" + scope);
+ }
+
+ /** 调度建任务:按日期+订阅唯一键幂等,重复跑不出第二行;未授权订阅一条也不建 */
+ public int createScheduledTasks(LocalDate businessDate) {
+ Long cursor = 0L;
+ int created = 0;
+ int pageSize = Math.max(1, properties.getPageSize());
+ while (created < properties.getMaxTasksPerRun()) {
+ List grants = readRepository.findLiveSubscriptionsAfterId(cursor,
+ PageRequest.of(0, pageSize));
+ if (grants.isEmpty()) {
+ break;
+ }
+ for (FormConsumerGrant grant : grants) {
+ cursor = grant.getId();
+ String key = "D:" + businessDate + ":" + grant.getConsumerAppKey()
+ + ":" + grant.getConsumerSchoolId() + ":" + grant.getFormUuid();
+ if (taskRepository.findByIdempotencyKey(key).isPresent()) {
+ continue;
+ }
+ if (taskRepository.countActiveAt(grant.getConsumerAppKey(), grant.getConsumerSchoolId(),
+ grant.getFormUuid()) > 0) {
+ continue; // 上一轮还没跑完:同一坐标不叠第二个在途任务
+ }
+ AnswerReconcileTask task = newTask(AnswerReconcileTask.TRIGGER_SCHEDULE, null, key,
+ grant.getConsumerAppKey(), grant.getConsumerSchoolId(), grant.getFormUuid());
+ if (task == null) {
+ continue;
+ }
+ created++;
+ if (created >= properties.getMaxTasksPerRun()) {
+ break;
+ }
+ }
+ }
+ log.info("[对账] 调度建任务 {} 条(营业日 {},上限 {})", created, businessDate,
+ properties.getMaxTasksPerRun());
+ return created;
+ }
+
+ private List createForAllSubscriptions(Long domain, String operator, TaskCreateRequest request) {
+ if (isBlank(request.getConsumerAppKey()) || request.getConsumerSchoolId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "ALL 范围必须指明 consumerAppKey 与 consumerSchoolId");
+ }
+ requireRegisteredConsumer(request.getConsumerAppKey());
+ String appKey = request.getConsumerAppKey().trim();
+ Long schoolId = request.getConsumerSchoolId();
+ List views = new ArrayList<>();
+ Long cursor = 0L;
+ int pageSize = Math.max(1, properties.getPageSize());
+ int made = 0;
+ while (made < properties.getMaxTasksPerRun()) {
+ List grants = readRepository.findLiveSubscriptionsAfterId(cursor,
+ PageRequest.of(0, pageSize));
+ if (grants.isEmpty()) {
+ break;
+ }
+ for (FormConsumerGrant grant : grants) {
+ cursor = grant.getId();
+ if (!appKey.equals(grant.getConsumerAppKey()) || !schoolId.equals(grant.getConsumerSchoolId())) {
+ continue;
+ }
+ requireSameDomain(domain, grant.getConsumerSchoolId(), grant.getFormUuid());
+ String key = manualKey(request, appKey, schoolId, grant.getFormUuid());
+ if (taskRepository.findByIdempotencyKey(key).isPresent()) {
+ continue;
+ }
+ AnswerReconcileTask task = newTask(AnswerReconcileTask.TRIGGER_MANUAL, operator, key,
+ appKey, schoolId, grant.getFormUuid());
+ if (task != null) {
+ views.add(toTaskView(task));
+ made++;
+ }
+ if (made >= properties.getMaxTasksPerRun()) {
+ break;
+ }
+ }
+ if (grants.size() < pageSize) {
+ break;
+ }
+ }
+ if (views.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "该消费方在该租户域没有可建任务的生效订阅(或被上限/在途任务拦下)");
+ }
+ return views;
+ }
+
+ private TaskView createForAnswer(Long domain, String operator, TaskCreateRequest request) {
+ if (isBlank(request.getAnswerUuid())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "ANSWER 范围必须指明 answerUuid");
+ }
+ Answer answer = answerRepository.findByUuidAndSchId(request.getAnswerUuid().trim(),
+ request.getConsumerSchoolId()).orElse(null);
+ if (answer == null) {
+ // 跨校与不存在同口径(uuid 不可枚举,存在性本身就是探测面)
+ throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或不在该租户域:" + request.getAnswerUuid());
+ }
+ String appKey = request.getConsumerAppKey();
+ if (isBlank(appKey)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "ANSWER 范围必须指明 consumerAppKey");
+ }
+ // 单答卷任务同样不绕授权:答卷归属校就是它的消费租户域坐标,那一域上没有生效订阅
+ // 就不该建出一轮能修数据的任务(否则 ANSWER 范围会变成撤权后的后门)。
+ if (liveGrant(appKey.trim(), answer.getSchId(), answer.getFormUuid()) == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ ERR_GRANT_REVOKED + ":该答卷所属表单在这个消费方与租户域上没有生效订阅,不建对账任务");
+ }
+ requireSameDomain(domain, answer.getSchId(), answer.getFormUuid());
+ AnswerReconcileTask task = newTask(AnswerReconcileTask.TRIGGER_MANUAL, operator,
+ manualKey(request, appKey, answer.getSchId(), answer.getFormUuid()),
+ appKey, answer.getSchId(), answer.getFormUuid());
+ if (task == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "建任务失败:请查看管理端拒绝原因");
+ }
+ // 单答卷任务的范围只圈这一份答卷:清单里要么有它(比摘要),要么由权威缺席答复它已不存在
+ pinSingleAnswer(task, answer);
+ return toTaskView(task);
+ }
+
+ private TaskView createForForm(Long domain, String operator, String appKey, Long schoolId, String formUuid,
+ String triggerType, String idempotencyKey) {
+ if (isBlank(appKey) || schoolId == null || isBlank(formUuid)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "FORM 范围必须指明 consumerAppKey/consumerSchoolId/formUuid");
+ }
+ requireRegisteredConsumer(appKey);
+ requireSameDomain(domain, schoolId, formUuid.trim());
+ AnswerReconcileTask task = newTask(triggerType, operator, idempotencyKey, appKey, schoolId,
+ formUuid.trim());
+ if (task == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "建任务失败:请查看管理端拒绝原因");
+ }
+ return toTaskView(task);
+ }
+
+ /**
+ * 建任务的动作本体:授权、范围、并发、资源四道闸。
+ *
+ * 任一闸拦下都会落一行 {@code FAILED} 任务(带稳定错误码与原因),
+ * 因为"拒绝"必须是可查的事实,而不是操作人屏幕上闪过的一行字。
+ */
+ private AnswerReconcileTask newTask(String triggerType, String operator, String idempotencyKey,
+ String rawAppKey, Long schoolId, String formUuid) {
+ String appKey = rawAppKey.trim();
+ Optional existing = taskRepository.findByIdempotencyKey(idempotencyKey);
+ if (existing.isPresent()) {
+ return existing.get();
+ }
+ if (taskRepository.countActiveAt(appKey, schoolId, formUuid) > 0) {
+ return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid,
+ ERR_ACTIVE_TASK_EXISTS, "同一坐标已有在途对账任务(单并发),本轮不建第二条");
+ }
+ FormConsumerGrant grant = liveGrant(appKey, schoolId, formUuid);
+ Form form = formRepository.findByUuid(formUuid).orElse(null);
+ if (grant == null || form == null || Integer.valueOf(1).equals(form.getIsDeleted())) {
+ return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid,
+ ERR_GRANT_REVOKED, "没有生效订阅(撤权/缺读权/缺订阅权)或表单已删:未授权订阅不产生任务");
+ }
+ // 跨校防线:consumerSchoolId 就是答卷归属校(与投递行同口径)。TASK_2 §9#1 的
+ // "调用方属于该租户域"事实源还没建,这里宁可拒绝建任务,也不能把别校数据判成本轮范围。
+ if (form.getSchId() != null && !form.getSchId().equals(schoolId)
+ && form.getSchId() != Form.PLATFORM_SCH_ID) {
+ return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid,
+ ERR_SCOPE_MISMATCH, "订阅租户域(" + schoolId + ")与表单归属校(" + form.getSchId()
+ + ")不一致,跨校范围本轮拒绝(TASK_2 §9#1 未闭环前不放行)");
+ }
+ long rows = readRepository.countScope(formUuid, schoolId);
+ int pageSize = Math.max(1, properties.getPageSize());
+ int pages = (int) Math.ceil(rows / (double) pageSize);
+ if (rows > properties.getMaxRows() || pages > properties.getMaxPages()) {
+ return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid,
+ ERR_RESOURCE_LIMIT, "范围内答卷 " + rows + " 行 / " + pages + " 页,超过上限(max-rows="
+ + properties.getMaxRows() + ", max-pages=" + properties.getMaxPages()
+ + "):请缩范围或分批");
+ }
+ AnswerReconcileTask task = new AnswerReconcileTask();
+ task.setTaskUuid(newServerUuid());
+ task.setConsumerAppKey(appKey);
+ task.setConsumerSchoolId(schoolId);
+ task.setFormUuid(formUuid);
+ task.setFormVersion(form.getCurrentVersion() == null ? 1 : form.getCurrentVersion());
+ task.setTriggerType(triggerType);
+ task.setOperator(operator);
+ task.setIdempotencyKey(idempotencyKey);
+ task.setState(AnswerReconcileTask.STATE_QUEUED);
+ task.setManifestState(AnswerReconcileTask.MANIFEST_PENDING);
+ task.setGrantVersion(grant.getGrantVersion() == null ? 1 : grant.getGrantVersion());
+ // 任务起点=建任务时刻(比物化更早):晚于此刻新增的本地行一律不可判为多余行
+ task.setWindowStart(LocalDateTime.now());
+ return taskRepository.save(task);
+ }
+
+ private AnswerReconcileTask rejected(String triggerType, String operator, String idempotencyKey,
+ String appKey, Long schoolId, String formUuid,
+ String errorCode, String reason) {
+ AnswerReconcileTask task = new AnswerReconcileTask();
+ task.setTaskUuid(newServerUuid());
+ task.setConsumerAppKey(appKey);
+ task.setConsumerSchoolId(schoolId);
+ task.setFormUuid(formUuid);
+ task.setFormVersion(1);
+ task.setTriggerType(triggerType);
+ task.setOperator(operator);
+ task.setIdempotencyKey(idempotencyKey);
+ task.setState(AnswerReconcileTask.STATE_FAILED);
+ task.setManifestState(AnswerReconcileTask.MANIFEST_FAILED);
+ task.setErrorCode(errorCode);
+ task.setErrorDetail(truncate(reason, 500));
+ task.setWindowStart(LocalDateTime.now());
+ task.setFinishedAt(LocalDateTime.now());
+ AnswerReconcileTask saved = taskRepository.save(task);
+ log.warn("[对账] 拒绝建任务 consumer={}/{} form={} 原因={}:{}", appKey, schoolId, formUuid,
+ errorCode, reason);
+ return saved;
+ }
+
+ /** 单答卷任务:清单只有这一行(或零行=缺席),totalPages 因此是确定的 1 */
+ private void pinSingleAnswer(AnswerReconcileTask task, Answer answer) {
+ transactionTemplate.executeWithoutResult(tx -> {
+ AnswerReconcileManifestRow row = new AnswerReconcileManifestRow();
+ row.setTaskId(task.getId());
+ row.setTaskUuid(task.getTaskUuid());
+ row.setFormUuid(answer.getFormUuid());
+ row.setFormVersion(answer.getVersion());
+ row.setAnswerUuid(answer.getUuid());
+ row.setRevision(answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision());
+ row.setContentHash(snapshotService.contentHash(answer));
+ row.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()) ? 1 : 0);
+ row.setSeqNo(0);
+ manifestRepository.save(row);
+ task.setManifestState(AnswerReconcileTask.MANIFEST_COMPLETE);
+ task.setManifestComplete(1);
+ task.setTotalRows(1);
+ task.setTotalPages(1);
+ task.setMaterializedRows(1);
+ task.setMaterializedPages(1);
+ task.setCheckpointCursor(answer.getUuid());
+ task.setWindowEnd(LocalDateTime.now());
+ taskRepository.save(task);
+ });
+ }
+
+ // ==================== 2. 物化不可变清单 ====================
+
+ /** 扫描入口:每 tick 只推一条任务(任务级单并发),异常不外抛(闹钟不能因为一次物化失败而停) */
+ public int materializeNextQuietly() {
+ List pending = taskRepository.findPendingMaterialization(PageRequest.of(0, 1));
+ if (pending.isEmpty()) {
+ pending = taskRepository.findStaleMaterialization(LocalDateTime.now(), PageRequest.of(0, 1));
+ }
+ if (pending.isEmpty()) {
+ return 0;
+ }
+ AnswerReconcileTask task = pending.get(0);
+ Integer claimed = transactionTemplate.execute(tx ->
+ taskRepository.claimMaterialization(task.getId(), LocalDateTime.now(),
+ LocalDateTime.now().plusSeconds(properties.getLeaseSeconds())));
+ if (claimed == null || claimed != 1) {
+ return 0;
+ }
+ try {
+ materialize(task.getId());
+ return 1;
+ } catch (ResourceLimitException e) {
+ markFailed(task.getId(), ERR_RESOURCE_LIMIT, e.getMessage());
+ log.warn("[对账] 物化超上限 task={}:{}", task.getTaskUuid(), e.getMessage());
+ return 0;
+ } catch (RuntimeException e) {
+ markFailed(task.getId(), ERR_MANIFEST_INCOMPLETE,
+ "物化中断(整笔回滚,检查点随之作废):" + brief(e));
+ log.warn("[对账] 物化失败 task={}:{}", task.getTaskUuid(), e.toString());
+ return 0;
+ }
+ }
+
+ /**
+ * 一个事务=一轮一致读:清单行、计数、结束标记同生同死。
+ *
+ * 中途崩溃时检查点与已写行一起回滚,重启从头再物化才是正确的——
+ * 半截检查点配上半截清单,会让"清单里没有"被误判成权威缺席。
+ */
+ private void materialize(Long taskId) {
+ transactionTemplate.executeWithoutResult(tx -> {
+ AnswerReconcileTask task = taskRepository.findByIdForUpdate(taskId)
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
+ int pageSize = Math.max(1, properties.getPageSize());
+ String cursor = task.getCheckpointCursor() == null ? "" : task.getCheckpointCursor();
+ int seq = Math.max(-1, manifestRepository.maxSeqNo(taskId)) + 1;
+ int rows = 0;
+ int pages = 0;
+ String last = null;
+ while (true) {
+ List batch = readRepository.findScopeAfterUuid(task.getFormUuid(),
+ task.getConsumerSchoolId(), cursor, PageRequest.of(0, pageSize));
+ pages++;
+ if (pages > properties.getMaxPages() || rows + batch.size() > properties.getMaxRows()) {
+ throw new ResourceLimitException("物化过程中超过上限(已写 " + rows + " 行 / 已读 " + pages
+ + " 页,max-rows=" + properties.getMaxRows() + ", max-pages="
+ + properties.getMaxPages() + ")");
+ }
+ for (Answer answer : batch) {
+ AnswerReconcileManifestRow row = new AnswerReconcileManifestRow();
+ row.setTaskId(task.getId());
+ row.setTaskUuid(task.getTaskUuid());
+ row.setFormUuid(task.getFormUuid());
+ row.setFormVersion(answer.getVersion());
+ row.setAnswerUuid(answer.getUuid());
+ row.setRevision(answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision());
+ row.setContentHash(snapshotService.contentHash(answer));
+ row.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()) ? 1 : 0);
+ row.setSeqNo(seq++);
+ manifestRepository.save(row);
+ cursor = answer.getUuid();
+ last = answer.getUuid();
+ }
+ rows += batch.size();
+ if (batch.size() < pageSize) {
+ break; // 一致读内的短页=到底;不是"某页空了"
+ }
+ }
+ task.setMaterializedRows(rows);
+ task.setMaterializedPages(pages);
+ task.setTotalRows(rows);
+ task.setTotalPages(Math.max(1, (int) Math.ceil(rows / (double) pageSize)));
+ task.setCheckpointCursor(last);
+ task.setManifestState(AnswerReconcileTask.MANIFEST_COMPLETE);
+ task.setManifestComplete(1);
+ task.setWindowEnd(LocalDateTime.now());
+ task.setLeaseUntil(null);
+ taskRepository.save(task);
+ });
+ }
+
+ // ==================== 3. 派发与租约 ====================
+
+ public int dispatchNextQuietly() {
+ List due = taskRepository.findDispatchable(LocalDateTime.now(),
+ PageRequest.of(0, 1));
+ if (due.isEmpty()) {
+ return 0;
+ }
+ AnswerReconcileTask candidate = due.get(0);
+ // 同一消费方一次只跑一轮:两个 Worker 同时扫同一份本地集合会把检查点踩成花
+ if (transactionTemplate.execute(tx -> taskRepository.countInFlightForConsumer(candidate.getConsumerAppKey(),
+ candidate.getConsumerSchoolId(), candidate.getId())) > 0) {
+ return 0;
+ }
+ Integer claimed = transactionTemplate.execute(tx -> taskRepository.claimForDispatch(candidate.getId(),
+ LocalDateTime.now(), LocalDateTime.now().plusSeconds(properties.getLeaseSeconds())));
+ if (claimed == null || claimed != 1) {
+ return 0;
+ }
+ AnswerReconcileTask task = taskRepository.findById(candidate.getId()).orElse(candidate);
+ int attempt = task.getAttempt() == null ? 1 : task.getAttempt();
+ AnswerReconcileDispatchClient.DispatchOutcome outcome = dispatchClient == null
+ ? AnswerReconcileDispatchClient.DispatchOutcome.of(false, ERR_TARGET_NOT_REGISTERED,
+ "出站签名客户端未就绪", attempt, null, false)
+ : dispatchClient.dispatch(instructionOf(task, attempt));
+ if (outcome.isAccepted()) {
+ log.info("[对账] 指令已被对端收下 task={} attempt={}(不代表执行完成)", task.getTaskUuid(), attempt);
+ return 1;
+ }
+ if (attempt >= properties.getMaxDispatchAttempts()) {
+ markFailed(task.getId(), outcome.getErrorCode(),
+ "派发连续失败到上限(第 " + attempt + " 次):" + outcome.getMessage());
+ } else {
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.rescheduleDispatch(task.getId(),
+ LocalDateTime.now().plusSeconds(backoffSeconds(attempt)), outcome.getErrorCode(),
+ truncate(outcome.getMessage(), 500)));
+ }
+ return 0;
+ }
+
+ /** 租约超期:还有预算就按 attempt+1 退避重派,耗尽即 FAILED(业务结果按 taskId/attempt 幂等,不会重复计) */
+ public int recoverExpiredLeasesQuietly() {
+ LocalDateTime now = LocalDateTime.now();
+ List expired = taskRepository.findExpiredLease(now, PageRequest.of(0, 5));
+ int handled = 0;
+ for (AnswerReconcileTask task : expired) {
+ int attempt = task.getAttempt() == null ? 1 : task.getAttempt();
+ if (Integer.valueOf(1).equals(task.getCancelRequested())) {
+ transactionTemplate.executeWithoutResult(tx ->
+ taskRepository.markCancelled(task.getId(), now, "取消收敛:租约到期后无人回报"));
+ handled++;
+ continue;
+ }
+ if (attempt >= properties.getMaxDispatchAttempts()) {
+ markFailed(task.getId(), ERR_LEASE_EXPIRED,
+ "租约到期且派发次数到上限(第 " + attempt + " 次):对端没有回报结果,不猜它的状态");
+ handled++;
+ continue;
+ }
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.requeueExpiredLease(task.getId(), now,
+ now.plusSeconds(backoffSeconds(attempt)), ERR_LEASE_EXPIRED,
+ "租约到期,按第 " + (attempt + 1) + " 次重派(同 taskId 幂等续跑)"));
+ handled++;
+ }
+ return handled;
+ }
+
+ private ReconcileInstruction instructionOf(AnswerReconcileTask task, int attempt) {
+ ReconcileInstruction instruction = new ReconcileInstruction();
+ instruction.setTaskId(task.getTaskUuid());
+ instruction.setAttempt(attempt);
+ instruction.setConsumerAppKey(task.getConsumerAppKey());
+ instruction.setConsumerSchoolId(task.getConsumerSchoolId());
+ instruction.setFormUuid(task.getFormUuid());
+ instruction.setFormVersion(task.getFormVersion());
+ instruction.setTriggerType(task.getTriggerType());
+ instruction.setWindowStart(format(task.getWindowStart()));
+ instruction.setMaterializedAt(format(task.getWindowEnd()));
+ instruction.setTotalRows(task.getTotalRows());
+ instruction.setTotalPages(task.getTotalPages());
+ instruction.setPageSize(Math.max(1, properties.getPageSize()));
+ instruction.setIssuedAt(format(LocalDateTime.now()));
+ return instruction;
+ }
+
+ private long backoffSeconds(int attempt) {
+ long base = Math.max(1, properties.getBackoffBaseSeconds());
+ long cap = Math.max(base, properties.getBackoffCapSeconds());
+ int n = Math.min(Math.max(0, attempt), 20);
+ long value = base * (1L << n);
+ return value <= 0 || value > cap ? cap : value;
+ }
+
+ // ==================== 4. 消费端答复(清单 / 权威快照 / 缺席 / 结果)====================
+
+ /**
+ * 清单分页:只读本任务物化出来的不可变清单。
+ *
+ * {@code completed} 由"多取一条"给出,与总行数/总页数相互印证;空页不等于结束,
+ * 结束只有 {@code completed=true} 这一种表达。
+ */
+ public ManifestPageView manifestPage(ConsumerPrincipal caller, String taskId, String cursor, Integer size) {
+ AnswerReconcileTask task = requireAccessible(caller, taskId);
+ if (!Integer.valueOf(1).equals(task.getManifestComplete())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, ERR_MANIFEST_INCOMPLETE + ":清单尚未完整,不能开分页");
+ }
+ int pageSize = clampPageSize(size);
+ String from = normalizeCursor(cursor);
+ List fetched = manifestRepository.findPage(task.getId(), from,
+ PageRequest.of(0, pageSize + 1));
+ boolean completed = fetched.size() <= pageSize;
+ List rows = new ArrayList<>(
+ fetched.subList(0, Math.min(pageSize, fetched.size())));
+ ManifestPageView view = new ManifestPageView();
+ view.setTaskId(task.getTaskUuid());
+ view.setTaskState(task.getState());
+ view.setTotalRows(task.getTotalRows());
+ view.setTotalPages(task.getTotalPages());
+ view.setCompleted(completed);
+ for (AnswerReconcileManifestRow row : rows) {
+ ManifestRowView item = new ManifestRowView();
+ item.setAnswerUuid(row.getAnswerUuid());
+ item.setRevision(row.getRevision());
+ item.setContentHash(row.getContentHash());
+ item.setIsDeleted(Integer.valueOf(1).equals(row.getIsDeleted()));
+ item.setFormUuid(row.getFormUuid());
+ item.setFormVersion(row.getFormVersion());
+ view.getRows().add(item);
+ }
+ view.setNextCursor(completed ? null : rows.get(rows.size() - 1).getAnswerUuid());
+ return view;
+ }
+
+ /**
+ * 任务快照答复:修复入口的唯一权威来源。
+ *
+ * 坐标全部取中心已核验的值:{@code formUuid} 来自任务行,{@code formVersion} 来自中心当前答卷行
+ * (不从旧清单猜版本),{@code grantVersion} 来自刚复核的生效 grant,快照与 hash 走
+ * {@code AnswerSnapshotService#currentAuthorized}(与投递行同一条 fc.canon.v1 口径)。
+ *
+ * 缺席只在清单完整 + 此刻中心确实没有这份答卷 + 授权新鲜三条都成立时签发证明;
+ * 期间新增(中心现在有了、清单里没有)只给原因,不发证明。
+ */
+ public TaskAnswerReply taskAnswer(ConsumerPrincipal caller, String taskId, String answerUuid,
+ Long expectedRevision) {
+ AnswerReconcileTask task = requireAccessible(caller, taskId);
+ String uuid = requireText(answerUuid, "answerUuid 不能为空");
+ TaskAnswerReply reply = new TaskAnswerReply();
+ reply.setTaskId(task.getTaskUuid());
+ reply.setConsumerAppKey(task.getConsumerAppKey());
+ reply.setConsumerSchoolId(task.getConsumerSchoolId());
+ reply.setFormUuid(task.getFormUuid());
+ reply.setAnswerUuid(uuid);
+ reply.setVerifiedAt(format(LocalDateTime.now()));
+ reply.setGrantVersion(task.getGrantVersion());
+
+ Answer answer = answerRepository.findByUuidAndSchId(uuid, task.getConsumerSchoolId()).orElse(null);
+ if (answer != null && !task.getFormUuid().equals(answer.getFormUuid())) {
+ reply.setPresence(PRESENCE_UNKNOWN);
+ reply.setReasonCode(ERR_ANSWER_NOT_IN_SCOPE);
+ reply.setReason("答卷所属表单与任务范围不符,本轮不给它作权威结论");
+ return reply;
+ }
+ if (answer != null) {
+ SnapshotView current = snapshotService.currentAuthorized(caller, task.getConsumerSchoolId(), uuid);
+ reply.setPresence(PRESENCE_PRESENT);
+ reply.setFormVersion(answer.getVersion());
+ reply.setEnvelope(envelopeOf(task, current, answer));
+ return reply;
+ }
+ if (!Integer.valueOf(1).equals(task.getManifestComplete())) {
+ reply.setPresence(PRESENCE_UNKNOWN);
+ reply.setReasonCode(ERR_MANIFEST_INCOMPLETE);
+ reply.setReason("清单尚未完整结束,任何缺席结论都不成立");
+ return reply;
+ }
+ if (manifestRepository.existsByTaskIdAndAnswerUuid(task.getId(), uuid)) {
+ // 清单里有、当前读不到:本轮事实源仍是清单(快照读不出=授权或数据面另有原因),不发缺席证明
+ reply.setPresence(PRESENCE_UNKNOWN);
+ reply.setReasonCode(ERR_MANIFEST_INCOMPLETE);
+ reply.setReason("清单内答卷当前读不到权威快照,只报告差异,不签缺席证明");
+ return reply;
+ }
+ if (expectedRevision == null || expectedRevision < 1) {
+ reply.setPresence(PRESENCE_UNKNOWN);
+ reply.setReasonCode(ERR_EXPECTED_REVISION_REQUIRED);
+ reply.setReason("缺席证明必须绑定本地扫描到的 expectedRevision(消费端并发护栏,不是 FC 断言)");
+ return reply;
+ }
+ reply.setPresence(PRESENCE_ABSENT);
+ reply.setAbsenceProof(absenceProof(task, uuid, expectedRevision));
+ return reply;
+ }
+
+ /**
+ * 结果受理:{@code (taskId, attempt)} 幂等,旧 attempt 不倒退新 attempt。
+ *
+ * 身份必须等于任务目标(跨校/冒充在此拒);错误明细逐字段搬进来并截断,
+ * 上限之外的明细丢弃但计数照留——明细是给人看方向的,不是给日志堆值的。
+ */
+ @org.springframework.transaction.annotation.Transactional
+ public ResultAck submitResult(ConsumerPrincipal caller, String taskId, ResultReport report) {
+ taskRepository.lockByTaskUuid(requireText(taskId, "taskId 必填"));
+ AnswerReconcileTask task = requireAccessible(caller, taskId, true);
+ if (report == null || report.getAttempt() == null || report.getAttempt() < 1) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "attempt 必填且为正整数");
+ }
+ if (isBlank(report.getState())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "state 必填");
+ }
+ int attempt = report.getAttempt();
+ int dispatchedAttempt = task.getAttempt() == null ? 0 : task.getAttempt();
+ if (attempt > dispatchedAttempt) {
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "回报的 attempt=" + attempt + " 超过中心已派发的 " + dispatchedAttempt);
+ }
+ Optional repeat = resultRepository.findByTaskIdAndAttempt(task.getId(), attempt);
+ if (AnswerReconcileTask.isTerminal(task.getState())
+ || (repeat.isPresent() && !AnswerReconcileTask.STATE_RUNNING.equals(repeat.get().getState()))) {
+ ResultAck ack = toResultAck(task);
+ ack.setAccepted(true);
+ ack.setDuplicate(true);
+ ack.setMessage("任务或本轮结果已终结,仅幂等确认,不恢复执行");
+ return ack;
+ }
+ String reportState = normalizeReportState(report.getState());
+ if (AnswerReconcileTask.STATE_COMPLETED.equals(reportState)
+ && (!Boolean.TRUE.equals(report.getManifestComplete())
+ || !Boolean.TRUE.equals(report.getLocalScanComplete())
+ || !Integer.valueOf(1).equals(task.getManifestComplete()))) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "INCOMPLETE_RESULT:两侧扫描未完整结束,不能回报COMPLETED");
+ }
+ Integer latest = resultRepository.maxAttempt(task.getId());
+ boolean staleAttempt = attempt < dispatchedAttempt || (latest != null && attempt < latest);
+ if (repeat.isPresent() && AnswerReconcileTask.STATE_RUNNING.equals(reportState) && !staleAttempt) {
+ advanceByReport(task, report, repeat.get());
+ return toResultAck(taskRepository.findById(task.getId()).orElse(task));
+ }
+ AnswerReconcileResult row = persistResult(task, attempt, report);
+ ResultAck ack = toResultAck(task);
+ ack.setAccepted(true);
+ ack.setDuplicate(false);
+ ack.setStaleAttempt(staleAttempt);
+ if (staleAttempt) {
+ ack.setMessage("旧 attempt=" + attempt + " 的回报只留档,不推进任务状态(最新 attempt=" + latest + ")");
+ return ack;
+ }
+ advanceByReport(task, report, row);
+ return toResultAck(taskRepository.findById(task.getId()).orElse(task));
+ }
+
+ private AnswerReconcileResult persistResult(AnswerReconcileTask task, int attempt, ResultReport report) {
+ List errors = sanitizeErrors(report.getErrors(), task);
+ String errorsJson = writeJson(errors);
+ while (errorsJson.length() > Math.max(2, properties.getMaxErrorChars()) && !errors.isEmpty()) {
+ errors.remove(errors.size() - 1);
+ errorsJson = writeJson(errors); // Keep valid JSON, rather than cutting in the middle of a value.
+ }
+ AnswerReconcileResult row = new AnswerReconcileResult();
+ // A RUNNING progress record may advance once to a terminal result for the same attempt.
+ resultRepository.findByTaskIdAndAttempt(task.getId(), attempt).ifPresent(previous -> row.setId(previous.getId()));
+ row.setTaskId(task.getId());
+ row.setTaskUuid(task.getTaskUuid());
+ row.setConsumerAppKey(task.getConsumerAppKey());
+ row.setConsumerSchoolId(task.getConsumerSchoolId());
+ row.setFormUuid(task.getFormUuid());
+ row.setAttempt(attempt);
+ row.setState(normalizeReportState(report.getState()));
+ row.setLocalScanComplete(Boolean.TRUE.equals(report.getLocalScanComplete()) ? 1 : 0);
+ row.setManifestComplete(Boolean.TRUE.equals(report.getManifestComplete()) ? 1 : 0);
+ row.setScannedRows(nonNegative(report.getScannedRows()));
+ row.setPagesProcessed(nonNegative(report.getPagesProcessed()));
+ row.setMissingRepaired(nonNegative(report.getMissingRepaired()));
+ row.setStaleRepaired(nonNegative(report.getStaleRepaired()));
+ row.setHashConflicts(nonNegative(report.getHashConflicts()));
+ row.setTombstonesRepaired(nonNegative(report.getTombstonesRepaired()));
+ row.setLocalAhead(nonNegative(report.getLocalAhead()));
+ row.setDeferred(nonNegative(report.getDeferred()));
+ row.setExtraReported(nonNegative(report.getExtraReported()));
+ row.setExtraRetired(nonNegative(report.getExtraRetired()));
+ row.setErrorCount(nonNegative(report.getErrors() == null ? 0 : report.getErrors().size()));
+ row.setErrorsJson(errorsJson);
+ row.setResumed(Boolean.TRUE.equals(report.getResumed()) ? 1 : 0);
+ row.setReportedAt(LocalDateTime.now());
+ // Task condition updates clear the persistence context; flush the result before that happens.
+ return resultRepository.saveAndFlush(row);
+ }
+
+ private void advanceByReport(AnswerReconcileTask task, ResultReport report, AnswerReconcileResult row) {
+ LocalDateTime now = LocalDateTime.now();
+ String state = row.getState();
+ if (AnswerReconcileTask.STATE_RUNNING.equals(state)) {
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.renewRunning(task.getId(), row.getAttempt(),
+ now.plusSeconds(properties.getLeaseSeconds())));
+ task.setState(AnswerReconcileTask.STATE_RUNNING);
+ return;
+ }
+ String errorCode = AnswerReconcileTask.STATE_FAILED.equals(state) ? "REPORTED_FAILED" : null;
+ String detail = AnswerReconcileTask.STATE_FAILED.equals(state)
+ ? "消费端回报 FAILED:见结果明细 attempt=" + row.getAttempt() : null;
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.finishAttempt(task.getId(), row.getAttempt(), state, now,
+ errorCode, detail));
+ task.setState(state);
+ }
+
+ private String normalizeReportState(String raw) {
+ String state = raw.trim().toUpperCase();
+ switch (state) {
+ case AnswerReconcileTask.STATE_QUEUED:
+ throw new BizException(ErrorCode.PARAM_ERROR, "回报状态不能是 QUEUED(那是中心派发前的状态)");
+ case AnswerReconcileTask.STATE_COMPLETED:
+ case AnswerReconcileTask.STATE_PARTIAL:
+ case AnswerReconcileTask.STATE_FAILED:
+ case AnswerReconcileTask.STATE_CANCELLED:
+ case AnswerReconcileTask.STATE_RUNNING:
+ return state;
+ default:
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "回报状态不在闭集内(COMPLETED/PARTIAL/FAILED/CANCELLED/RUNNING):" + raw);
+ }
+ }
+
+ /** 逐字段搬运并截断:consumer/task/range/answer 四坐标都补齐成任务口径,值文档无处进来 */
+ private List sanitizeErrors(List raw, AnswerReconcileTask task) {
+ List out = new ArrayList<>();
+ if (raw == null) {
+ return out;
+ }
+ int limit = Math.max(1, properties.getMaxErrorItems());
+ for (ReconcileError error : raw) {
+ if (error == null || out.size() >= limit) {
+ continue;
+ }
+ ReconcileError clean = new ReconcileError();
+ clean.setCode(isBlank(error.getCode()) ? "UNSPECIFIED" : error.getCode().trim().toUpperCase());
+ clean.setTaskId(task.getTaskUuid());
+ clean.setConsumerAppKey(task.getConsumerAppKey());
+ clean.setConsumerSchoolId(task.getConsumerSchoolId());
+ clean.setFormUuid(task.getFormUuid());
+ clean.setAnswerUuid(isBlank(error.getAnswerUuid()) ? null : error.getAnswerUuid().trim());
+ clean.setRevision(error.getRevision());
+ clean.setDetail(truncate(error.getDetail(), ERROR_DETAIL_MAX));
+ out.add(clean);
+ }
+ return out;
+ }
+
+ private ResultAck toResultAck(AnswerReconcileTask task) {
+ ResultAck ack = new ResultAck();
+ ack.setTaskId(task.getTaskUuid());
+ ack.setAttempt(task.getAttempt());
+ ack.setTaskState(task.getState());
+ return ack;
+ }
+
+ // ==================== 5. 管理面 ====================
+
+ public Page taskPage(Long domain, String appKey, Long schoolId, String state,
+ Integer page, Integer size) {
+ if (domain != null && domain > 0 && schoolId != null && !domain.equals(schoolId)) {
+ throw new BizException(ErrorCode.FORBIDDEN, "校级只能查看本校域的对账任务(当前域 " + domain + ")");
+ }
+ int pageIndex = page == null || page < 1 ? 0 : page - 1;
+ int pageSize = size == null || size < 1 ? 20 : Math.min(size, 100);
+ Long scoped = domain != null && domain > 0 ? domain : schoolId;
+ Page rows = taskRepository.findForAdmin(isBlank(appKey) ? null : appKey.trim(),
+ scoped, isBlank(state) ? null : state.trim().toUpperCase(),
+ PageRequest.of(pageIndex, pageSize));
+ List views = new ArrayList<>();
+ for (AnswerReconcileTask task : rows.getContent()) {
+ views.add(toTaskView(task, true));
+ }
+ return new PageImpl<>(views, rows.getPageable(), rows.getTotalElements());
+ }
+
+ public TaskView taskDetail(Long domain, String taskId) {
+ return toTaskView(loadTaskInDomain(domain, taskId), true);
+ }
+
+ public List results(Long domain, String taskId) {
+ AnswerReconcileTask task = loadTaskInDomain(domain, taskId);
+ List views = new ArrayList<>();
+ for (AnswerReconcileResult row : resultRepository.findByTask(task.getId())) {
+ views.add(toResultView(row));
+ }
+ return views;
+ }
+
+ public TaskView cancel(Long domain, String taskId, BoaUser user) {
+ AnswerReconcileTask task = loadTaskInDomain(domain, taskId);
+ LocalDateTime now = LocalDateTime.now();
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.markCancelled(task.getId(), now,
+ "管理面取消(操作人 " + operatorOf(user) + "):剩余投递与修复一律停"));
+ return toTaskView(taskRepository.findById(task.getId()).orElse(task), true);
+ }
+
+ /**
+ * 单答卷重放(管理面→TASK_2 {@code replayAuthorized})。
+ *
+ * 先重新 ACL 再执行:身份取任务/请求里申报的消费方,但必须①在平台密钥注册表里查得到,
+ * ②对该表单有当前生效的读权,③答卷就在这个租户域里——三条都在 {@code requireRead} 与
+ * {@code replayAuthorized} 内部复核过,本方法只是不把它们绕开。
+ */
+ public DeliveryStateView replayAnswer(BoaUser user, Long domain, String consumerAppKey, Long consumerSchoolId,
+ String answerUuid, Long expectedRevision, String operationId) {
+ requireSameDomain(domain, consumerSchoolId, null);
+ requireRegisteredConsumer(consumerAppKey);
+ if (isBlank(answerUuid) || expectedRevision == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "answerUuid 与 expectedRevision 必填");
+ }
+ Answer answer = answerRepository.findByUuidAndSchId(answerUuid.trim(), consumerSchoolId).orElse(null);
+ if (answer == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或不在该租户域:" + answerUuid);
+ }
+ ConsumerPrincipal caller = new ConsumerPrincipal(consumerAppKey.trim());
+ accessService.requireRead(caller, consumerSchoolId, answer.getFormUuid());
+ log.info("[对账] 管理面重放 operator={} consumer={}/{} answer={} 期望 rev={}", operatorOf(user),
+ consumerAppKey, consumerSchoolId, answerUuid, expectedRevision);
+ return deliveryService.replayAuthorized(caller, answerUuid.trim(), expectedRevision,
+ isBlank(operationId) ? newServerUuid() : operationId.trim());
+ }
+
+ /** 投递失败分页转视图(运维入口,直接转发 TASK_2 的 failedPage,不另拼一套查询) */
+ public com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage failedDeliveries(
+ Long domain, String consumerAppKey, Long consumerSchoolId, Integer page, Integer size) {
+ requireSameDomain(domain, consumerSchoolId, null);
+ requireRegisteredConsumer(consumerAppKey);
+ return deliveryService.failedPage(new ConsumerPrincipal(consumerAppKey.trim()), consumerSchoolId,
+ page, size);
+ }
+
+ // ==================== 6. 内部:核验与装配 ====================
+
+ /** 每次消费端读取都要过的核验:任务存在、身份等于目标、未取消、最新 grant 仍生效且纪元未进位 */
+ private AnswerReconcileTask requireAccessible(ConsumerPrincipal caller, String taskId) {
+ return requireAccessible(caller, taskId, false);
+ }
+
+ private AnswerReconcileTask requireAccessible(ConsumerPrincipal caller, String taskId, boolean resultOnly) {
+ if (caller == null || isBlank(caller.getAppKey())) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
+ }
+ AnswerReconcileTask task = loadTask(taskId);
+ if (!caller.getAppKey().trim().equals(task.getConsumerAppKey())) {
+ // 不是"参数不对",是冒充:别的消费方拿不到别人任务的清单与快照
+ throw new BizException(ErrorCode.FORBIDDEN, "任务目标消费方与已验签身份不符");
+ }
+ if (resultOnly && AnswerReconcileTask.isTerminal(task.getState())) {
+ return task; // Identity already checked; acknowledgements disclose no answer payload.
+ }
+ if (task.isCancelled()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, ERR_CANCELLED + ":任务已取消,剩余投递与修复停止");
+ }
+ if (AnswerReconcileTask.isTerminal(task.getState())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "TASK_TERMINAL:任务已终结,不再签发清单、快照或缺席证明");
+ }
+ FormConsumerGrant grant = liveGrant(task.getConsumerAppKey(), task.getConsumerSchoolId(),
+ task.getFormUuid());
+ if (grant == null) {
+ LocalDateTime now = LocalDateTime.now();
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.markCancelled(task.getId(), now,
+ "核验时发现授权已撤:取消剩余投递与修复"));
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ ERR_GRANT_REVOKED + ":当前订阅授权已失效,任务已按取消处置");
+ }
+ Integer current = grant.getGrantVersion() == null ? 1 : grant.getGrantVersion();
+ if (!current.equals(task.getGrantVersion())) {
+ LocalDateTime now = LocalDateTime.now();
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.markCancelled(task.getId(), now,
+ "授权纪元由 " + task.getGrantVersion() + " 进位到 " + current + ":旧轮结论作废"));
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ ERR_GRANT_REVOKED + ":授权纪元已进位(" + task.getGrantVersion() + "→" + current
+ + "),任务已按取消处置");
+ }
+ return task;
+ }
+
+ private AnswerReconcileTask loadTask(String taskId) {
+ AnswerReconcileTask task = isBlank(taskId) ? null
+ : taskRepository.findByTaskUuid(taskId.trim()).orElse(null);
+ if (task == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, ERR_TASK_NOT_FOUND + ":" + taskId);
+ }
+ return task;
+ }
+
+ /** 跨域的管理面寻址一律按"任务不存在"回答(存在性差别就是探测面,与数据面同口径) */
+ private AnswerReconcileTask loadTaskInDomain(Long domain, String taskId) {
+ AnswerReconcileTask task = loadTask(taskId);
+ if (domain != null && domain > 0 && !domain.equals(task.getConsumerSchoolId())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, ERR_TASK_NOT_FOUND + ":" + taskId);
+ }
+ return task;
+ }
+
+ private FormConsumerGrant liveGrant(String appKey, Long schoolId, String formUuid) {
+ try {
+ ConsumerPrincipal principal = new ConsumerPrincipal(appKey);
+ accessService.requireRead(principal, schoolId, formUuid);
+ accessService.requireSubscribe(principal, schoolId, formUuid);
+ } catch (BizException e) {
+ return null;
+ }
+ return grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(formUuid, schoolId, appKey)
+ .orElse(null); // Metadata only; authorization above has one shared decision point.
+ }
+
+ /** 修复信封:坐标取中心已核验值,eventId 用服务端 UUID(≤36),意图块只对本人可见 */
+ private SnapshotEnvelope envelopeOf(AnswerReconcileTask task, SnapshotView view, Answer answer) {
+ SnapshotEnvelope envelope = new SnapshotEnvelope();
+ envelope.setProtocolVersion(com.yunhai.formcenter.model.AnswerDeliveryModels.PROTOCOL_VERSION);
+ envelope.setEventId(newServerUuid());
+ envelope.setEventType(Boolean.TRUE.equals(view.getIsDeleted())
+ ? com.yunhai.formcenter.model.AnswerDeliveryModels.EVENT_TOMBSTONED
+ : (view.getAnswerRevision() != null && view.getAnswerRevision() <= 1L
+ ? com.yunhai.formcenter.model.AnswerDeliveryModels.EVENT_CREATED
+ : com.yunhai.formcenter.model.AnswerDeliveryModels.EVENT_UPDATED));
+ envelope.setSourceAppKey(sourceAppKey());
+ envelope.setConsumerAppKey(task.getConsumerAppKey());
+ envelope.setConsumerSchoolId(task.getConsumerSchoolId());
+ envelope.setGrantVersion(task.getGrantVersion());
+ envelope.setFormUuid(task.getFormUuid());
+ envelope.setFormVersion(answer == null ? task.getFormVersion() : answer.getVersion());
+ envelope.setAnswerUuid(view.getAnswerUuid());
+ envelope.setAnswerRevision(view.getAnswerRevision());
+ envelope.setIsDeleted(view.getIsDeleted());
+ envelope.setContentHash(view.getContentHash());
+ envelope.setOccurredAt(format(LocalDateTime.now()));
+ envelope.setSnapshot(view.getSnapshot());
+ return envelope;
+ }
+
+ private AbsenceProof absenceProof(AnswerReconcileTask task, String answerUuid, Long expectedRevision) {
+ AbsenceProof proof = new AbsenceProof();
+ proof.setProofId(truncate("abs:" + task.getTaskUuid() + ":" + answerUuid + ":" + expectedRevision,
+ PROOF_ID_MAX));
+ proof.setTaskId(task.getTaskUuid());
+ proof.setFormUuid(task.getFormUuid());
+ proof.setSchId(task.getConsumerSchoolId());
+ proof.setAnswerUuid(answerUuid);
+ proof.setExpectedRevision(expectedRevision);
+ proof.setGrantVersion(task.getGrantVersion());
+ proof.setIssuedAt(format(LocalDateTime.now()));
+ return proof;
+ }
+
+ private String sourceAppKey() {
+ if (!isBlank(properties.getSourceAppKey())) {
+ return properties.getSourceAppKey().trim();
+ }
+ return isBlank(platformAppKey) ? "" : platformAppKey.trim();
+ }
+
+ private TaskView toTaskView(AnswerReconcileTask task) {
+ return toTaskView(task, false);
+ }
+
+ private TaskView toTaskView(AnswerReconcileTask task, boolean withLatestResult) {
+ TaskView view = new TaskView();
+ view.setTaskId(task.getTaskUuid());
+ view.setConsumerAppKey(task.getConsumerAppKey());
+ view.setConsumerSchoolId(task.getConsumerSchoolId());
+ view.setFormUuid(task.getFormUuid());
+ view.setFormVersion(task.getFormVersion());
+ view.setTriggerType(task.getTriggerType());
+ view.setOperator(task.getOperator());
+ view.setState(task.getState());
+ view.setManifestState(task.getManifestState());
+ view.setManifestComplete(Integer.valueOf(1).equals(task.getManifestComplete()));
+ view.setTotalRows(task.getTotalRows());
+ view.setTotalPages(task.getTotalPages());
+ view.setMaterializedRows(task.getMaterializedRows());
+ view.setMaterializedPages(task.getMaterializedPages());
+ view.setAttempt(task.getAttempt());
+ view.setWindowStart(format(task.getWindowStart()));
+ view.setWindowEnd(format(task.getWindowEnd()));
+ view.setDispatchedAt(format(task.getDispatchedAt()));
+ view.setFinishedAt(format(task.getFinishedAt()));
+ view.setCancelRequested(Integer.valueOf(1).equals(task.getCancelRequested()));
+ view.setErrorCode(task.getErrorCode());
+ view.setErrorDetail(task.getErrorDetail());
+ if (withLatestResult) {
+ resultRepository.findFirstByTaskIdOrderByIdDesc(task.getId())
+ .ifPresent(row -> view.setLatestResult(toResultView(row)));
+ }
+ return view;
+ }
+
+ private ResultView toResultView(AnswerReconcileResult row) {
+ ResultView view = new ResultView();
+ view.setTaskId(row.getTaskUuid());
+ view.setAttempt(row.getAttempt());
+ view.setState(row.getState());
+ view.setLocalScanComplete(Integer.valueOf(1).equals(row.getLocalScanComplete()));
+ view.setManifestComplete(Integer.valueOf(1).equals(row.getManifestComplete()));
+ view.setScannedRows(row.getScannedRows());
+ view.setPagesProcessed(row.getPagesProcessed());
+ view.setMissingRepaired(row.getMissingRepaired());
+ view.setStaleRepaired(row.getStaleRepaired());
+ view.setHashConflicts(row.getHashConflicts());
+ view.setTombstonesRepaired(row.getTombstonesRepaired());
+ view.setLocalAhead(row.getLocalAhead());
+ view.setDeferred(row.getDeferred());
+ view.setExtraReported(row.getExtraReported());
+ view.setExtraRetired(row.getExtraRetired());
+ view.setErrorCount(row.getErrorCount());
+ view.setErrors(readErrors(row.getErrorsJson()));
+ view.setResumed(Integer.valueOf(1).equals(row.getResumed()));
+ view.setReportedAt(format(row.getReportedAt()));
+ return view;
+ }
+
+ private List readErrors(String json) {
+ if (isBlank(json)) {
+ return new ArrayList<>();
+ }
+ try {
+ return objectMapper.readValue(json, new TypeReference>() {
+ });
+ } catch (Exception e) {
+ // 明细读不出来时宁可回空表,也不能把对账列表接口拖成 500
+ log.warn("[对账] 结果明细解析失败(按空明细返回):{}", e.toString());
+ return new ArrayList<>();
+ }
+ }
+
+ private String writeJson(Object value) {
+ try {
+ return objectMapper.writeValueAsString(value);
+ } catch (Exception e) {
+ return "[]";
+ }
+ }
+
+ private void markFailed(Long taskId, String errorCode, String detail) {
+ LocalDateTime now = LocalDateTime.now();
+ transactionTemplate.executeWithoutResult(tx -> taskRepository.advanceTerminal(taskId,
+ AnswerReconcileTask.STATE_FAILED, now, errorCode, truncate(detail, 500)));
+ }
+
+ private void requireRegisteredConsumer(String appKey) {
+ if (isBlank(appKey) || !callerResolver.isRegisteredAppKey(appKey)) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "consumerAppKey 不是平台已注册应用:" + appKey);
+ }
+ }
+
+ /** 校级管理员只能动自己域的对账;超管可跨域(与 grant 管理同一把判据) */
+ private void requireSameDomain(Long domain, Long schoolId, String formUuid) {
+ if (schoolId == null || schoolId <= 0) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)");
+ }
+ if (domain != null && domain > 0 && !domain.equals(schoolId)) {
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "对账范围只能落在本校域(当前域 " + domain + ",目标 " + schoolId + ")");
+ }
+ if (formUuid != null && domain != null && domain > 0) {
+ Form form = formRepository.findByUuid(formUuid).orElse(null);
+ if (form != null && form.getSchId() != null
+ && !form.getSchId().equals(domain) && form.getSchId() != Form.PLATFORM_SCH_ID) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid);
+ }
+ }
+ }
+
+ private String operatorOf(BoaUser user) {
+ if (user == null) {
+ return "system";
+ }
+ if (!isBlank(user.getAccount())) {
+ return user.getAccount().trim();
+ }
+ return user.getId() == null ? "system" : String.valueOf(user.getId());
+ }
+
+ private int clampPageSize(Integer size) {
+ int defaultSize = Math.max(1, properties.getPageSize());
+ return size == null || size < 1 ? defaultSize : Math.min(size, 500);
+ }
+
+ private String manualKey(TaskCreateRequest request, String appKey, Long schoolId, String formUuid) {
+ String given = request == null ? null : request.getIdempotencyKey();
+ String prefix = isBlank(given) ? newServerUuid() : given.trim();
+ return truncate("M:" + prefix + ":" + appKey + ":" + schoolId + ":" + formUuid, 128);
+ }
+
+ private String normalizeCursor(String cursor) {
+ if (cursor == null || cursor.trim().isEmpty()) {
+ return "";
+ }
+ String clean = cursor.trim();
+ if (clean.length() != ANSWER_UUID_LEN || !clean.matches("[a-fA-F0-9]{32}")) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "cursor 必须是上一页返回的答卷 UUID");
+ }
+ return clean;
+ }
+
+ private String newServerUuid() {
+ String uuid = UUID.randomUUID().toString().replace("-", "").toLowerCase();
+ return uuid.length() <= EVENT_ID_MAX ? uuid : uuid.substring(0, EVENT_ID_MAX);
+ }
+
+ private static String requireText(String raw, String message) {
+ if (raw == null || raw.trim().isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, message);
+ }
+ return raw.trim();
+ }
+
+ private static boolean isBlank(String raw) {
+ return raw == null || raw.trim().isEmpty();
+ }
+
+ private static int nonNegative(Integer value) {
+ return value == null || value < 0 ? 0 : value;
+ }
+
+ private static String truncate(String raw, int max) {
+ if (raw == null) {
+ return null;
+ }
+ return raw.length() <= max ? raw : raw.substring(0, max);
+ }
+
+ private static String format(LocalDateTime value) {
+ return value == null ? null : TS.format(value);
+ }
+
+ private static String brief(Exception e) {
+ String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+ return message.length() > 300 ? message.substring(0, 300) : message;
+ }
+
+ /** 物化过程中的资源上限突破:整笔回滚,由扫描落成 FAILED+原因 */
+ private static final class ResourceLimitException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ ResourceLimitException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java
index 2298815..41a71d6 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java
@@ -8,7 +8,9 @@ import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.FillQuota;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile;
+import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.enums.FormEnums;
+import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.AnonModels;
import com.yunhai.formcenter.repository.AnswerRepository;
@@ -93,6 +95,10 @@ public class AnswerService {
private final AnswerIndexService indexService;
private final TransactionTemplate transactionTemplate;
private final FcEventPublisher events;
+ /** v2 投递(outbox 必须在答卷事务内落库,TASK_2 B.1/B.4) */
+ private final AnswerDeliveryService deliveries;
+ /** 可信提交上下文(hosted 链路的令牌兑换与意图原子关联,TASK_2 A.3) */
+ private final SubmissionContextService contexts;
/**
* 提交答卷。
@@ -101,7 +107,8 @@ public class AnswerService {
*/
public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey());
- return executeSubmit(SubmitIdentity.ofSession(user), req, idempotencyKey, ANSWER_NO_RETRY);
+ return executeSubmit(SubmitIdentity.ofSession(user, req.getContextToken()), req, idempotencyKey,
+ ANSWER_NO_RETRY);
}
/**
@@ -142,6 +149,9 @@ public class AnswerService {
// 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (replay != null && identity.ownsReplay(replay)) {
+ if (identity.hosted()) {
+ requireHostedReplay(identity, req, replay);
+ }
log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid());
return toView(replay, true);
}
@@ -176,10 +186,36 @@ public class AnswerService {
String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
? DEFAULT_APP_CODE : req.getAppCode().trim();
// 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。
- // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56)
- FormDefService.Published published = identity.anonymous()
- ? defService.resolvePublishedForAnon(appCode, req.getFormCode())
- : defService.resolvePublished(sessionSchId, appCode, req.getFormCode());
+ // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56)。
+ // hosted 是第三条取址分支,且它是"更严"的那条:坐标只认令牌里钉死的 uuid+version,
+ // 既不走 code 遮蔽,也绝不回落到当前发布版(TASK_2 A.3)
+ SubmissionContext hostedCtx = null;
+ FormDefService.Published published;
+ if (identity.hosted()) {
+ if (req.getSubjectId() != null && !req.getSubjectId().equals(identity.submitterId())) {
+ throw new BizException(ErrorCode.FORBIDDEN, "托管填报仅允许登录本人提交,不支持代填");
+ }
+ hostedCtx = contexts.redeem(identity.contextToken(), user, sessionSchId);
+ contexts.assertCoordinates(hostedCtx, appCode, req.getFormCode());
+ Answer alreadyLinked = contexts.linkedAnswer(hostedCtx);
+ if (alreadyLinked != null) {
+ // 同 token 同幂等键=原答卷重放(仍 200,契约 §5);换幂等键再用来交第二份=令牌复用,拒
+ if (idempotencyKey.equals(alreadyLinked.getIdempotencyKey())) {
+ requireHostedReplay(identity, req, alreadyLinked);
+ log.info("[hosted] 同意图重放返回原答卷 intent={} answer={}", hostedCtx.getIntentId(),
+ alreadyLinked.getUuid());
+ return toView(alreadyLinked, true);
+ }
+ throw new BizException(ErrorCode.CUSTOM,
+ "该提交上下文已完成过一次提交,不能换幂等键重复使用(请回到办事页面重新发起)");
+ }
+ published = defService.resolvePublishedByUuid(sessionSchId, hostedCtx.getFormUuid(),
+ hostedCtx.getFormVersion());
+ } else {
+ published = identity.anonymous()
+ ? defService.resolvePublishedForAnon(appCode, req.getFormCode())
+ : defService.resolvePublished(sessionSchId, appCode, req.getFormCode());
+ }
Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson();
Long schId = identity.anonymous() ? form.getSchId() : sessionSchId;
@@ -204,6 +240,9 @@ public class AnswerService {
if (!identity.ownsReplay(existing)) {
throw replayConflict(identity);
}
+ if (identity.hosted()) {
+ requireHostedReplay(identity, req, existing);
+ }
return toView(existing, true);
}
@@ -255,6 +294,8 @@ public class AnswerService {
answer.setValuesJson(valuesJson);
answer.setAnswerNo(nextAnswerNo(form));
answer.setIdempotencyKey(idempotencyKey);
+ // 首版快照号=1(之后每次对外可见的变化由 AnswerDeliveryService.nextRevision 行级 CAS 推进)
+ answer.setAnswerRevision(1L);
if (identity.anonymous()) {
// 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据)
answer.setAnonIp(identity.anon().getAnonIp());
@@ -267,13 +308,24 @@ public class AnswerService {
// 9) 索引:只写 searchable 字段(V3/V9)
indexService.write(answer, schemaJson);
+ // 9.5) 可信关联:意图 → 答卷。必须与答卷同一事务(A.3"原子关联"),
+ // 并且排在投递入队之前——outbox 载荷里的 intent 块就是从这里读的。
+ if (hostedCtx != null) {
+ contexts.link(hostedCtx, answer);
+ }
+
+ // 9.6) v2 快照投递入队(同事务,B.1/B.4):无订阅的简单表单不建任何投递行,
+ // 答卷照常提交(B.2 末句);撞同 revision 的唯一键则整事务回滚,不产生两份 hash
+ deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_CREATED);
+
// 10) 事件:created 只在"真提交"这条路上发(幂等命中在第 4 关就 return 了,不发事件)。
// 发布器内部是 afterCommit + 失败仅 warn + 未配 broker 即 no-op,主流程零风险(§6.2 裁决 2/3)
events.answerCreated(answer);
- log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}",
- identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(),
- answer.getVersion(), submitterId, storedSubjectId, quotaKey);
+ log.info("[提交]{}{} answer={} form={}/{} v{} rev={} submitter={} subject={} quotaKey={}",
+ identity.anonymous() ? "[匿名]" : "", identity.hosted() ? "[hosted]" : "", answer.getUuid(),
+ answer.getAppCode(), answer.getCode(), answer.getVersion(), answer.getAnswerRevision(),
+ submitterId, storedSubjectId, quotaKey);
return toView(answer, false);
}
@@ -438,6 +490,29 @@ public class AnswerService {
return UUID.randomUUID().toString().replace("-", "").toLowerCase();
}
+ private void requireHostedReplay(SubmitIdentity identity, AnswerModels.SubmitRequest request, Answer answer) {
+ Long schoolId = FormAccessPolicy.requireSessionSchId(identity.user, "当前身份无学校归属");
+ SubmissionContext context = contexts.redeem(identity.contextToken(), identity.user, schoolId);
+ Answer linked = contexts.linkedAnswer(context);
+ String app = request.getAppCode() == null || request.getAppCode().trim().isEmpty()
+ ? DEFAULT_APP_CODE : request.getAppCode().trim();
+ contexts.assertCoordinates(context, app, request.getFormCode());
+ if (linked == null || !answer.getUuid().equals(linked.getUuid())
+ || !context.getFormUuid().equals(answer.getFormUuid())
+ || !context.getFormVersion().equals(answer.getVersion())
+ || !schoolId.equals(answer.getSchId())
+ || (request.getSubjectId() != null && !request.getSubjectId().equals(identity.submitterId()))) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "幂等键已被其他提交占用,不能跨意图复用");
+ }
+ com.fasterxml.jackson.databind.JsonNode stored = com.yunhai.formcenter.common.FormSchemaValidator
+ .parse(answer.getValuesJson(), "答卷值文档损坏");
+ com.fasterxml.jackson.databind.JsonNode requested = request.getValues() == null
+ ? com.yunhai.formcenter.common.FormSchemaValidator.mapper().createObjectNode() : request.getValues();
+ if (!AnswerCanonicalizer.canonical(stored).equals(AnswerCanonicalizer.canonical(requested))) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "同一提交上下文与幂等键不能携带不同内容重放");
+ }
+ }
+
private AnswerModels.SubmitView toView(Answer answer, boolean replayed) {
AnswerModels.SubmitView view = new AnswerModels.SubmitView();
view.setAnswerId(answer.getUuid());
@@ -463,19 +538,23 @@ public class AnswerService {
private final BoaUser user;
private final AnonModels.Context anon;
private final boolean anonymous;
+ /** 可信提交上下文令牌(hosted 链路才有;null=中心自用的普通提交) */
+ private final String contextToken;
- private SubmitIdentity(BoaUser user, AnonModels.Context anon, boolean anonymous) {
+ private SubmitIdentity(BoaUser user, AnonModels.Context anon, boolean anonymous, String contextToken) {
this.user = user;
this.anon = anon;
this.anonymous = anonymous;
+ this.contextToken = contextToken == null || contextToken.trim().isEmpty() ? null : contextToken.trim();
}
- static SubmitIdentity ofSession(BoaUser user) {
- return new SubmitIdentity(user, null, false);
+ static SubmitIdentity ofSession(BoaUser user, String contextToken) {
+ return new SubmitIdentity(user, null, false, contextToken);
}
static SubmitIdentity ofAnon(AnonModels.Context anon) {
- return new SubmitIdentity(null, anon, true);
+ // 匿名分支永远不带令牌:hosted 首批只允许登录本人,匿名完成消息不能用于本人办件(A.3)
+ return new SubmitIdentity(null, anon, true, null);
}
boolean anonymous() {
@@ -486,6 +565,14 @@ public class AnswerService {
return anon;
}
+ String contextToken() {
+ return contextToken;
+ }
+
+ boolean hosted() {
+ return contextToken != null;
+ }
+
/** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */
Integer submitterId() {
return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId();
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerSnapshotService.java b/src/main/java/com/yunhai/formcenter/service/AnswerSnapshotService.java
new file mode 100644
index 0000000..ebdba3d
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerSnapshotService.java
@@ -0,0 +1,158 @@
+package com.yunhai.formcenter.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.yunhai.formcenter.common.FormSchemaValidator;
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.entity.SubmissionContext;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.Snapshot;
+import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotView;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.repository.AnswerRepository;
+import com.yunhai.formcenter.repository.SubmissionContextRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 对外答卷快照的唯一装配点(answer-delivery-v2 §2/§4,TASK_2 B.1/B.2、TASK_3 扩展点)。
+ *
+ * 成员闭集就是契约:{@code schId/appCode/code/submitterId/subjectId/status/values/
+ * answerNo/createTime/updateTime},再加一个仅对该 consumer 可见的 {@code intent} 关联块。
+ * 匿名 IP/UA 是审计数据、库内 {@code id} 是内部坐标,两者都不进快照(v2 契约 §2 明文);
+ * 附件保持 {@code {id,fileName,key}} 元数据,永远不塞文件字节(P34 的口径)。
+ *
+ * 消费端不回查值文档(B.6):快照里给的就是全文,投出去即完整。因此本类的输出与
+ * {@link AnswerCanonicalizer} 的哈希口径必须是同一份字节——两处都从这里取,不给分叉机会。
+ *
+ * {@code intent} 块剔除出哈希(fc.canon.v1 规则⑥):同一 revision 对 A 应用带意图、
+ * 对 B 应用不带,若不剔除就会出现"同版两份 hash",直接违 B.1 的不变式。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnswerSnapshotService {
+
+ /**
+ * 固定时间形态:{@code yyyy-MM-dd'T'HH:mm:ss}(秒恒出现)。
+ * 不用 {@code ISO_LOCAL_DATE_TIME}——它会在秒为 0 时省掉秒段,
+ * 于是 {@code 10:00:00} 与 {@code 10:00} 两种字节串都合法,跨端复算的哈希就再也不能逐字比。
+ */
+ private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+ private final ObjectMapper objectMapper;
+ private final AnswerRepository answerRepository;
+ private final SubmissionContextRepository contextRepository;
+ private final FormConsumerAccessService accessService;
+
+ /** 核心快照(不含 intent 关联块)——哈希按它算 */
+ public Snapshot core(Answer answer) {
+ Snapshot snapshot = new Snapshot();
+ snapshot.setSchId(answer.getSchId());
+ snapshot.setAppCode(answer.getAppCode());
+ snapshot.setCode(answer.getCode());
+ snapshot.setSubmitterId(answer.getSubmitterId());
+ snapshot.setSubjectId(answer.getSubjectId());
+ snapshot.setStatus(answer.getStatus());
+ snapshot.setValues(parseValues(answer));
+ snapshot.setAnswerNo(answer.getAnswerNo());
+ snapshot.setCreateTime(format(answer.getCreateTime()));
+ snapshot.setUpdateTime(format(answer.getUpdateTime()));
+ return snapshot;
+ }
+
+ /** 带意图关联的快照(载荷与哈希分离:intent 只进载荷) */
+ public Snapshot forConsumer(Answer answer, String consumerAppKey, Long consumerSchoolId) {
+ Snapshot snapshot = core(answer);
+ JsonNode intent = intentBlock(answer.getUuid(), consumerAppKey, consumerSchoolId);
+ if (intent != null) {
+ snapshot.setIntent(intent);
+ }
+ return snapshot;
+ }
+
+ /** 核心快照的规范串与哈希(入队时算一次,落库不可变) */
+ public String contentHash(Answer answer) {
+ return AnswerCanonicalizer.hashSnapshot(toJson(core(answer)));
+ }
+
+ public String canonicalCore(Answer answer) {
+ return AnswerCanonicalizer.canonical(toJson(core(answer)));
+ }
+
+ /**
+ * TASK_3 扩展点:授权下的当前快照读取(对账/补投用,签名消费方直取)。
+ *
+ * 判据只有 TASK_1 那把 {@code requireRead}(读快照=读答卷数据),租户域按
+ * {@code consumerSchoolId} 限定行集——平台级表单也给不出别校的答卷。
+ * 返回的 revision/hash 与投递行同一条规范化口径,对账两侧可比对。
+ */
+ public SnapshotView currentAuthorized(ConsumerPrincipal caller, Long consumerSchoolId, String answerUuid) {
+ Answer answer = answerRepository.findByUuidAndSchId(requireText(answerUuid, "answerUuid 不能为空"),
+ consumerSchoolId).orElse(null);
+ if (answer == null) {
+ // 不存在与跨校同一口径(uuid 不可枚举,契约 §1)
+ throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid);
+ }
+ accessService.requireRead(caller, consumerSchoolId, answer.getFormUuid());
+ SnapshotView view = new SnapshotView();
+ view.setAnswerUuid(answer.getUuid());
+ view.setAnswerRevision(answer.getAnswerRevision());
+ view.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()));
+ view.setContentHash(contentHash(answer));
+ view.setSnapshot(forConsumer(answer, caller == null ? null : caller.getAppKey(), consumerSchoolId));
+ return view;
+ }
+
+ /** 快照 → JSON(ObjectMapper 只做结构转换,序列化字节由 canonicalizer 定) */
+ public JsonNode toJson(Snapshot snapshot) {
+ return objectMapper.valueToTree(snapshot);
+ }
+
+ public JsonNode parseSnapshotJson(String json) {
+ return FormSchemaValidator.parse(json, "投递载荷损坏");
+ }
+
+ private JsonNode intentBlock(String answerUuid, String consumerAppKey, Long consumerSchoolId) {
+ if (consumerAppKey == null) {
+ return null;
+ }
+ SubmissionContext ctx = contextRepository.findByAnswerUuid(answerUuid).orElse(null);
+ if (ctx == null || !consumerAppKey.equals(ctx.getConsumerAppKey())) {
+ return null;
+ }
+ if (consumerSchoolId != null && !consumerSchoolId.equals(ctx.getConsumerSchoolId())) {
+ return null;
+ }
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("intentId", ctx.getIntentId());
+ node.put("consumerAppKey", ctx.getConsumerAppKey());
+ node.put("consumerSchoolId", ctx.getConsumerSchoolId());
+ node.put("submitterId", ctx.getSubmitterId());
+ node.put("formVersion", ctx.getFormVersion());
+ node.put("linkedRevision", ctx.getLinkedRevision());
+ return node;
+ }
+
+ private JsonNode parseValues(Answer answer) {
+ return FormSchemaValidator.parse(answer.getValuesJson() == null ? "{}" : answer.getValuesJson(),
+ "答卷值文档损坏:" + answer.getUuid());
+ }
+
+ private static String format(LocalDateTime time) {
+ return time == null ? null : TS.format(time);
+ }
+
+ private static String requireText(String raw, String message) {
+ if (raw == null || raw.trim().isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, message);
+ }
+ return raw.trim();
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/ConsumerPrincipalResolver.java b/src/main/java/com/yunhai/formcenter/service/ConsumerPrincipalResolver.java
new file mode 100644
index 0000000..32aae66
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/ConsumerPrincipalResolver.java
@@ -0,0 +1,72 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.appauth.AppSecretProvider;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 消费应用身份解析(v3 §1,TASK_1):把"平台验签后的 appKey"换成一个不可自报的主体对象。
+ *
+ * 身份只有一个来源:调用方必须带 {@code X-App-Key/X-Timestamp/X-Signature} 三件套,
+ * 由平台 {@code AppAuthFilter} 验签通过后才把 appKey 写进请求属性 {@code yh.app.auth}。
+ * 本类只转读那个属性,因此"没签名"与"签名不是我们的"在同一处就被关死——
+ * 请求里另有一个自由输入的 {@code appCode},它只是资源筛选项,永远不构成身份(v3 §1)。
+ *
+ * 签名密钥可来自trusted-apps,但它不证明UserBase登记事实。
+ * 因此另经OpenApiAppClient查询UserBase注册信息;不可达或查不到均拒绝。
+ * 这里只取存在性,密钥值不出本类。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ConsumerPrincipalResolver {
+
+ private static final int APP_KEY_MAX = 64;
+
+ private final AppSecretProvider appSecretProvider;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient;
+
+ /**
+ * @param appKey 取自已验签的请求属性;空/超格式/注册表查不到一律 {@code UNAUTHORIZED}
+ */
+ public ConsumerPrincipal resolveSignedCaller(String appKey) {
+ String clean = appKey == null ? null : appKey.trim();
+ if (clean == null || clean.isEmpty() || clean.length() > APP_KEY_MAX) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的应用身份(X-App-Key 三件套)");
+ }
+ if (appSecretProvider.getSecret(clean) == null || !isRegisteredAppKey(clean)) {
+ log.warn("[consumer ACL] appKey 在平台密钥注册表查不到,拒绝:appKey={}", clean);
+ throw new BizException(ErrorCode.UNAUTHORIZED, "已验签应用身份不可用");
+ }
+ return new ConsumerPrincipal(clean);
+ }
+
+ /**
+ * 管理面写入前的身份核验(v3 §2):owner/grant 里填的 appKey 必须是 UserBase 注册过的应用,
+ * 否则"绑一个不存在的 appKey"会把资源挂到空气上。只判存在,不回显密钥。
+ */
+ public boolean isRegisteredAppKey(String appKey) {
+ String clean = appKey == null ? null : appKey.trim();
+ if (clean == null || clean.isEmpty() || clean.length() > APP_KEY_MAX) {
+ return false;
+ }
+ if (appRegistryClient == null || !clean.matches("[0-9]+")) {
+ return false;
+ }
+ try {
+ com.fasterxml.jackson.databind.JsonNode registered = appRegistryClient.getAppByKey(clean);
+ return registered != null && !registered.isNull()
+ && clean.equals(registered.path("appKey").asText());
+ } catch (RuntimeException e) {
+ // Registry unavailable is not proof of registration; never fall back to trusted-apps.
+ log.warn("[consumer ACL] UserBase应用注册核验不可用 appKey={}", clean);
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/FormConsumerAccessService.java b/src/main/java/com/yunhai/formcenter/service/FormConsumerAccessService.java
new file mode 100644
index 0000000..fbc48ba
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/FormConsumerAccessService.java
@@ -0,0 +1,401 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.entity.FormConsumerGrant;
+import com.yunhai.formcenter.entity.FormOwnership;
+import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
+import com.yunhai.formcenter.model.FormAccessModels.CatalogItem;
+import com.yunhai.formcenter.model.FormAccessModels.GrantRequest;
+import com.yunhai.formcenter.model.FormAccessModels.GrantView;
+import com.yunhai.formcenter.model.FormAccessModels.OwnershipView;
+import com.yunhai.formcenter.model.FormAccessModels.RevokeRequest;
+import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant;
+import com.yunhai.formcenter.repository.FormConsumerGrantRepository;
+import com.yunhai.formcenter.repository.FormOwnershipRepository;
+import com.yunhai.formcenter.repository.FormRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 消费应用授权的唯一判定出口(v3 §2/§7,TASK_1)。
+ *
+ * 三段判定顺序照 v3 §1:①调用方身份({@link ConsumerPrincipalResolver},只认验签后的
+ * appKey)→ ②资源与归属(按 uuid 定位表单头表与其 owner 行)→ ③owner 或显式 grant。
+ * 三段里任何一段给不出肯定答案都是同一句拒绝:跨校、缺归属、不存在、未授权在对外文案上不可区分
+ * (uuid 是半公开坐标,"存在但你不配"这句话本身就是探测面,同契约 §1 的既有口径);
+ * 真实原因只进服务端 warn 日志。
+ *
+ * 读与订阅分权:owner 天然能读自己那张表的 schema/答卷(资源拥有者),但订阅
+ * 必须另开一行 grant——"能看"不等于"能往外推"。这也是 TASK_2/TASK_3 唯一的授权入口,
+ * 它们不允许再对着仓库拼第二套规则(v3 §7)。
+ *
+ * 租户维度:{@code consumerSchoolId} 既是授权的生效域,也是读答卷时的行集限定域。
+ * 平台级表单({@code form.sch_id=0})对某校授予后,那一校看到的答卷仍只有自己的行
+ * (行集由调用侧的 {@code schId} 条件保证,本服务不放宽它)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FormConsumerAccessService {
+
+ private final FormOwnershipRepository ownershipRepository;
+ private final FormConsumerGrantRepository grantRepository;
+ private final FormRepository formRepository;
+
+ // ==================== 消费面判定 ====================
+
+ /**
+ * 读授权(目录/schema/answer/answers 全走这一把,v3 §3)。
+ *
+ * @param consumerSchoolId 调用方声明的租户域;平台级表单靠它把行集限定到那一校
+ */
+ public void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid) {
+ Form form = requireForm(formUuid);
+ if (caller == null || !canRead(form, consumerSchoolId, caller.getAppKey())) {
+ deny(caller, consumerSchoolId, form, "read");
+ }
+ }
+
+ /**
+ * 订阅授权(v3 §7):owner 不算,必须有生效且 {@code subscribe_allowed=1} 的 grant。
+ * 返回带 {@code grantVersion} 的授权凭据,投递侧把它一起发出去。
+ */
+ public SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid) {
+ Form form = requireForm(formUuid);
+ if (caller == null || consumerSchoolId == null || consumerSchoolId <= 0
+ || !isOwnDomain(form, consumerSchoolId)) {
+ deny(caller, consumerSchoolId, form, "subscribe");
+ }
+ FormConsumerGrant grant = consumerSchoolId == null ? null
+ : grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(
+ form.getUuid(), consumerSchoolId, caller == null ? null : caller.getAppKey())
+ .filter(g -> isActive(g) && isOne(g.getSubscribeAllowed()))
+ .orElse(null);
+ if (grant == null) {
+ deny(caller, consumerSchoolId, form, "subscribe");
+ }
+ return toSubscriptionGrant(grant);
+ }
+
+ /**
+ * 这张表单在某校当前生效的订阅授权(TASK_3 投递目标清单,v3 §7)。
+ *
+ * 不带调用方:事件投递发生在提交链路里,那一刻没有"调用方应用",判据只能是
+ * "这张表 + 这份答卷所在校"上生效的订阅 grant。撤权后这里就查不到,后续投递自然断,
+ * 但已生成的任务不被改写(v3 §7)。
+ */
+ public List listEnabledSubscriptions(String formUuid, Long answerSchoolId) {
+ List out = new ArrayList<>();
+ if (formUuid == null || formUuid.trim().isEmpty() || answerSchoolId == null) {
+ return out;
+ }
+ Form form = formRepository.findByUuid(formUuid.trim()).orElse(null);
+ if (form == null || answerSchoolId <= 0 || !isOwnDomain(form, answerSchoolId)) {
+ return out;
+ }
+ for (FormConsumerGrant grant : grantRepository.findEnabledSubscriptions(formUuid.trim(), answerSchoolId)) {
+ out.add(toSubscriptionGrant(grant));
+ }
+ return out;
+ }
+
+ /**
+ * 目录用的批量读判定:一次取 owner 行 + 一次取该消费方在该校的生效 grant,
+ * 返回可读的 uuid 集(目录面对的是"这所学校的几十到几百张表",逐张判定会把 2 次查询放大成 N 次)。
+ */
+ public Set filterReadable(ConsumerPrincipal caller, Long consumerSchoolId, Collection