t1~t3落地+10-22精修: 消费授权v3(owner/grant/订阅/目录)+答卷投递v2(可信提交上下文/快照/幂等关联)+答卷对账v1(清单/派发/租约/结果)
build / build-push (push) Successful in 1m26s

含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。
合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
This commit is contained in:
chenxing
2026-10-22 10:02:57 +08:00
parent 4b76d616dd
commit d7473112b0
55 changed files with 10092 additions and 41 deletions
+300
View File
@@ -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.<appKey>.<schoolId>` |
| 授权 | 无(能连 broker 就能看到元数据) | 逐订阅授权 + 每条带 `grantVersion`,发布前复核 |
| 目标端点 | 无(消费方靠拉取) | 新增 `POST /oapi/form/submission-context`、`GET /oapi/form/submission-context/{intentId}/receipt`、`POST /oapi/form/answer-delivery/receipt`、`POST /oapi/form/answer-delivery/grants/verify` |
**v1 一条都不改**:字段闭集、routing key 形态、失败静默的取舍全部保留(`FcEventPublisherTest`
的十字段断言仍然成立)。差异登记见 §9 待修清单第 3 条。
## 2. 快照修订号 `answerRevision`
`answer` 新增 `answer_revision BIGINT NOT NULL DEFAULT 0`:创建=1,此后**每个会改变对外快照的路径**
在同事务内 +1,并与不可变 outbox 行一起落库。覆盖点(一处不漏):
| 路径 | 位置 | 事件类型 |
|---|---|---|
| 提交(手工/代录/导入/hosted) | `AnswerService#doSubmit` | `created` |
| 本人编辑值 | `AnswerReadService#editBySubmitter` | `updated` |
| 管理员编辑值 | `AnswerAdminService#doEdit` | `updated` |
| **改业务状态(含批量)** | `AnswerAdminService#doChangeStatus` | `updated` |
| 软删(本人 + 管理面 + 批量) | `softDeleteBySubmitter` / `doSoftDelete` | `tombstoned` |
并发控制是两道闸,缺一都可能让"同一 revision 对应两份内容":
1. **行级 CAS**:`AnswerRepository#compareAndSetRevision(id, expected, next)`(JPQL UPDATE 自带行锁;
必须在改动实体字段**之前**调用,否则自动 flush 会先把新号写进去、CAS 自我拒绝);
2. **outbox 唯一键** `uk_delivery_revision(answer_uuid, answer_revision, consumer_app_key,
consumer_school_id)`:CAS 没挡住的极端形态在这里撞键,整事务回滚。
撞键被翻译成 `BizException(CUSTOM)`,**故意不**透传 `DataIntegrityViolationException`——
提交链路外层会把 DIVE 当"幂等重放"返回原答卷,那等于把并发丢更新伪装成重放成功。
没上 JPA `@Version`:那会让存量的分离实体 `save()` 路径(并发 IT、批量导入)突然开始抛
`OptimisticLockException`,属于跨批次行为变更,不在本任务授权范围内。
## 3. 规范化与哈希:`fc.canon.v1`(跨端契约,两端各一份实现)
实现:form-center `AnswerCanonicalizer`、svc-hall `AnswerProjectionStore`(消费端复算)。
规则闭集(要改语义就升算法号,不许原地改):
1. 对象键按 **UTF-16 代码元字典序**(`String.compareTo`)升序递归输出——钉比较器而不是"码点序",
两端都是 JVM,同一个比较器才逐字一致;
2. 数组顺序保留(多选/排序是业务语义);
3. 数字 `decimalValue().stripTrailingZeros().toPlainString()`(`100.0→100`、`1E+2→100`,无指数);
4. 字符串只转义引号、反斜杠与控制字符(`\b\f\n\r\t` 短转义,其余 C0 用反斜杠+u+00+两位大写十六进制),
非 ASCII(含中文)原样输出,哈希取 UTF-8 字节;
5. **空值策略:显式 null 成员保留**(缺失键与 `"k":null` 是两种事实,合并会丢"字段被清空"这次变化);
6. 快照顶层 `intent` 成员**不参与哈希**(它只对单个 consumer 可见,留着就会出现"同 revision 两份 hash");
7. 时间恒为 `yyyy-MM-dd'T'HH:mm:ss`(不用 `ISO_LOCAL_DATE_TIME`:整分时它会省掉秒段,跨端就比不了);
8. 投递时间、重试计数、消费端扩展列都是信封/本地列,不进快照。
### 跨端夹具(逐字比对,两端测试钉同一串)
输入快照(十个成员):`schId=5, appCode="demo", code="honor_apply", submitterId=1001, subjectId=7,
status="processed", values={"a":{"z":1,"y":null},"b":[2,1],"n":100.0,"e":true},
answerNo=null, createTime="2026-10-21T10:00:00", updateTime=null`
规范串(`FcAnswerDeliveryTest.CANONICAL_VECTOR` = `ShAnswerFlowTest.CANONICAL_VECTOR`):
```json
{"answerNo":null,"appCode":"demo","code":"honor_apply","createTime":"2026-10-21T10:00:00","schId":5,"status":"processed","subjectId":7,"submitterId":1001,"updateTime":null,"values":{"a":{"y":null,"z":1},"b":[2,1],"e":true,"n":100}}
```
`contentHash` = 上述字节串的 SHA-256 小写十六进制(64 位)。
消费端复算不一致 ⇒ 毒载荷拒绝;与**已存投影行**不一致 ⇒ 冲突待核验不覆盖(§5)。
### 信封示例(闭集十五字段;`intent` 只在 `snapshot` 内、只对目标 consumer 出现)
```json
{"protocolVersion":"fc.answer.snapshot.v2","eventId":"5f0a…","eventType":"updated",
"sourceAppKey":"100021","consumerAppKey":"100018","consumerSchoolId":5,"grantVersion":7,
"formUuid":"f0e1…","formVersion":4,"answerUuid":"a1b2…","answerRevision":3,"isDeleted":false,
"contentHash":"<64hex>","occurredAt":"2026-10-21T10:05:00+08:00",
"snapshot":{"schId":5,"appCode":"demo","code":"honor_apply","submitterId":1001,"subjectId":1001,
"status":"processed","values":{},"answerNo":null,"createTime":"…","updateTime":"…",
"intent":{"intentId":"svchall-0123…","consumerAppKey":"100018","consumerSchoolId":5,
"submitterId":1001,"formVersion":4,"linkedRevision":1}}}
```
不进快照的:`anon_ip`/`anon_ua`(审计数据,契约 v2 C8/C12)、库内 `id`(契约 §1)、附件字节
(值里只有 `{id,fileName,key}`,P34)。**无订阅的简单表单不产生任何投递行**,答卷照常提交。
## 4. 可信提交上下文(A.1~A.4)
```
SH POST /api/apply/intents(body 只有 siteCode/itemCode/clientKey)
└ 会话取人与校 → 事项入口判定:已发布 ∧ applyUrl 为空 ∧ onlineFlag=1
∧ TASK_1 成对绑定(fcFormUuid+fcFormVersion) ∧ flowGuid 就绪 ← 任一不过:不建件、不调流程
└ 落 fc_apply_intent(uk: 校/人/事项/clientKey)
└ 签名 POST /oapi/form/submission-context(intentId=svchall-<自然键哈希>)
FC:resolveSignedCaller → requireRead + requireSubscribe
→ 按 (formUuid,formVersion) 精确取版本行 + 表单当前开放填报
→ embedOrigin / callbackUrl 取服务端配置(缺键即拒,浏览器 URL 一律不采信)
→ 同 (appKey,intentId) 回到同一行;令牌在该行上轮换(旧令牌即失效)
→ 存 SHA-256 摘要 + 过期 + 绑定坐标(默认 30 分钟)
└ 返回 fillPageUrl = {FC 前端基址}/f/{code}?mode=embed&appCode=…&ctx=<token>
被嵌页(form-center front Fill.vue,hosted 通道)
└ GET /api/form/hosted/schema?contextToken → 按令牌钉的那一版取 schema(绝不回落当前发布版)
└ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken)
FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域,
错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1)
→ 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 同事务写 outbox
→ 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝
└ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin**
(不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用)
SH POST /api/apply/intents/{id}/confirm("可确认"信号只触发这一步)
└ 签名 GET /oapi/form/submission-context/{intentId}/receipt
FC:只向所属消费应用返回 + 读取时再核一次 requireRead
→ PENDING(还没答卷,不建件) / SUBMITTED(坐标+提交者+revision+hash+快照)
└ 逐字复核 consumerAppKey / 学校 / 提交者 / formUuid+formVersion → 建件(一次)
```
**`bizId`/`answerId` 在浏览器可提交的形状里根本没有字段位**——"不能用浏览器传入 bizId 直接建件"
是结构事实,不是审查约定。匿名与普通提交仍是中心自用的两条既有通道;hosted 首批只允许登录本人,
匿名答卷的 `submitter_id=0` 与上下文断言的真人对不上,"匿名完成消息用于本人办件"这条路径
在 redeem 处就被结构性挡住。
## 5. 投影、inbox 与顺序无关合并(A.5/B.5)
三张表各管一件事,**没有第五张"双轨表"**:
| 表 | 唯一键 | 管什么 |
|---|---|---|
| `fc_apply_intent` | `(sch,submitter,item,clientKey)`、`(consumerAppKey,fcIntentId)`、`answer_uuid`、`apply_id` | 业务意图;"一次意图一个办件"的库级形态 |
| `fc_answer_projection` | `(answer_uuid, sch_id)`、`intent_id` | 答卷在本校的唯一投影 + **意图↔答卷↔办件的独立关联表**(保存 formUuid/formVersion/answerRevision/flowGuid) |
| `fc_answer_inbox` | `(event_id, consumer_app_key)` | 每封事件怎么处置的台账(APPLIED/IGNORED/CONFLICT/DEFERRED/REJECTED)+ 回执上报状态 |
规则(`AnswerProjectionStore#applySnapshot`,一个事务内完成,DB 提交成功才 manual ack):
- 更高 revision 才应用;更低忽略;同 revision 同 hash 幂等;同 revision 异 hash ⇒ `conflict_flag=1`
留证**不覆盖**(投影与台账同时记,等人工/对账核验);
- 墓碑只是"更高 revision 的一种取值",本地保留 revision;旧 `created` 复活不了它;
- `biz_ext_json` 是业务端扩展列,中心快照永不写;
- 每条事件**不回查答案值文档**(全文就在消息里);应用结果经 §4 的成对签名端点回执,
FC 按 `eventId + 消费方 + revision` 逐字复核并幂等记录;
- **没有已验证意图就不建件**:事件先到 ⇒ 只落 `UNLINKED` 投影;确认先到 ⇒ 意图行落
`ANSWERED/APPLIED`;后到的一方撞进同一组唯一键,两条路径收敛到同一个 intent/answer,
不会各建一个办件。
## 6. broker 隔离配置(实值来源,缺一条就别开 `RABBITMQ_HOST`)
v2 快照带完整值文档,"能连上 broker 的任何人可见"是不可接受的。以下每一项都是**部署契约的
待填实值**,不是建议:
| 项 | 实值来源 | 必须为真的判据 |
|---|---|---|
| vhost | projman 注入 `RABBITMQ_VHOST`(form-center 与 svc-hall 同一 vhost,与其他租户隔离) | 该 vhost 内不存在任何非授权消费者可绑的队列;跨应用共享 vhost 上**不得**发明文快照 |
| 发布方凭据 | `RABBITMQ_USER/PASS`(form-center 侧) | 账号权限:仅对 `fc.answer.delivery.v2` 有 `write`,对 `configure` 只覆盖自身 exchange 声明 |
| 消费方凭据 | svc-hall 侧同名 env | 只能 `configure`/`write`/`read` 自己前缀的队列与 binding:`svc-hall.fc.answer.snapshot.v2*`、`fc.snapshot.v2.<自身 appKey>.*`;**禁止** `#` 或他人前缀的通配绑定 |
| 队列与死信 | `FcAnswerMqConfiguration` 声明 | `svc-hall.fc.answer.snapshot.v2`(durable)+ `.dlq`(有界重试 3 次后不 requeue 落此,可查) |
| confirm/return | form-center `application.yml`:`publisher-confirm-type=correlated`、`publisher-returns=true`、`template.mandatory=true` | 拿不到逐条 confirm 就**不发送**(宁可积压 PENDING,也不按未确认投递) |
| 隔离能力确认 | 运维在交付单上逐项签字 | 上述任一项不成立时,v2 全量快照**不得**启用;此时只保留"签名收据"这条建件路径(本机联调即该形态) |
## 7. 故障状态表(两侧 + 恢复;按码处置,不靠文案)
### 7.1 form-center 投递行 `answer_delivery.state`
| 状态 | 含义 | 进入条件 | 离开 |
|---|---|---|---|
| `PENDING` | 已落库待发 | 答卷事务内入队;或未配 broker 时长期停留 | 扫描取出→发送 |
| `BROKER_ACCEPTED` | **总线收下** | confirm ack=true | 终态(业务侧结论看 `consumer_applied_at`) |
| `FAILED` | 发送异常/不可路由/broker nack | AmqpException、mandatory return、ack=false | 退避 `20s×2^n`(base/cap 可配)后重扫 |
| `DEAD` | 有界重试耗尽 | retry_count ≥ max-retries(8) | 人工/`failedPage` 处置 |
| `GRANT_REVOKED` | 发布前 grant 复核未过 | 订阅撤销或 `grantVersion` 进位 | 终态,**不重放旧 grant** |
**两个时刻分列**:`broker_accepted_at`(只由 confirm 回调写)与 `consumer_applied_at` +
`consumer_outcome`(只由消费方签名回执写)。broker 的 ack 永远不构成业务成功证据;
回执自称 `APPLIED` 但 hash 与信封不符 ⇒ 记 `CONFLICT`;同 eventId 重复上报不同结论 ⇒ 保留首次。
### 7.2 svc-hall inbox `state` / `report_state`
`APPLIED` / `IGNORED`(低 revision 或同版同 hash 重放)/ `CONFLICT`(同版异 hash,不覆盖)/
`DEFERRED`(授权无法确认,暂缓写入)/ `REJECTED`(闭集或哈希不过,毒载荷 ack 后留档);
`report_state`=`PENDING/SENT/FAILED` 由重报扫描追平(本地已落库但中心没收到确认时,
只补确认,不回滚事实)。
### 7.3 svc-hall `flow_apply_command.state`
| 状态 | 进入 | 处置 |
|---|---|---|
| `PENDING` | 与办件同短事务落库 | 事务外发送(HTTP 不占长事务) |
| `UNKNOWN` | **任何**发送异常(超时/断连/4xx/5xx 一律算未知) | 先 `GET /oapi/flow/instance` 按业务键查 |
| `DONE` | 发起成功,或查得远端 10/20/30/40 并回填真实状态 | 终态 |
| `FAILED` | 退避中(含"流程中心未接入",等接入后自愈) | `next_retry_at` 到点重扫 |
| `DEAD` | 重试预算耗尽(默认 6 次) | 人工处置;不无限重投 |
关键取舍:**不靠异常类型猜"远端有没有受理"**。查到 30/40 只回填镜像状态、**绝不重发 apply**
(重发会命中中心 R4 重启,把已拒绝/已撤回的件重新跑起来);只有查无才用同一 `requestToken` 补发。
### 7.4 flow-center 发起守卫分支(`FlowApplyRetryModels.ApplyResultV2.branch`,稳定码)
| 码 | 判据 | 是否重启流程 |
|---|---|---|
| `CREATED` | 无既有实例 | 新建 + START 推进 |
| `IDEMPOTENT` | 命中 10/20 | 否(既有语义逐字不变) |
| `SAME_REQUEST` | `requestToken` 与行上相同 | **否**——同一次命令的重投 |
| `STALE_ROUND` | 请求 `attempt` ≤ 行 `apply_round`(NULL 按 1) | 否 |
| `REPLAY_ONLY` | `retryOnly=true` 命中既有实例(含 30/40) | 否 |
| `RESTARTED` | 更高 `attempt` + 新 token 命中 30/40 | 是,走既有 R4 |
| `CONCURRENT_CREATED` | 唯一键撞键后事务边界外重读命中 | 否(且先核归属) |
归属复核三条:行 `apply_app_key`、`submitter_id`、流程线 `flowGuid` 与请求**任一明确不符即拒**,
不把别的应用相同 `bizType+bizId` 的办件当自己的结果。存量行(token/round 为 NULL)的守卫**缺省是关**。
### 7.5 时序
```
提交点(答卷事务)→ revision+1 → outbox 行 → [提交] → afterCommit 唤醒扫描 → 发送(persistent)
├ broker ack → broker_accepted_at (≠ 业务成功)
├ broker nack → FAILED + 退避
└ 不可路由/异常 → FAILED + 退避 →(耗尽)DEAD
消费方:manual ack 只在 DB 提交后;签名回执 → consumer_applied_at / consumer_outcome
进程崩溃窗口:outbox / flow_apply_command 都是库内行 → 重启后扫描续跑(不依赖内存闭包)
```
## 8. TASK_3 必须一次交付的扩展点(签名即契约,后续只调用不编辑)
form-center:`AnswerSnapshotService.currentAuthorized(ConsumerPrincipal, Long consumerSchoolId, String answerUuid)`、
`AnswerDeliveryService.replayAuthorized(ConsumerPrincipal, String answerUuid, Long expectedRevision, String operationId)`
(稳定码 `QUEUED/ALREADY_DELIVERED/GRANT_REVOKED/REVISION_MISMATCH/NO_DELIVERY_ROW`,
`operationId` 幂等吸收)、`AnswerDeliveryService.failedPage(ConsumerPrincipal, Long, Integer, Integer)`。
svc-hall:普通消费保留 `AnswerProjectionStore.applySnapshot(SnapshotEnvelope)`;对账修复与分页改用下方§8.1明确的
`repairAuthoritative(...)`、`manifestPage(...)`(只出轻量摘要与本地创建时间,不拉值文档)、
`retireAbsent(AuthoritativeAbsenceProof)`(只做退役标记,不物理删、不改 revision;
`expectedRevision` 与本地不符时**拒绝退役**并留证)。
快照适配与对账共用 §3 的 canonicalization/hash 契约,两端不得各写一套。
### 8.1 Task2 前置修正:对账与普通消费分离(2026-10-21)
- 普通 `applySnapshot` 继续按MQ规则处理,同revision异hash不覆盖,且可能关联意图并建件;**TASK_3不得使用它执行修复**。
- 新 `RepairResult repairAuthoritative(SnapshotEnvelope envelope, Long expectedRevision, String expectedHash, Integer expectedIsDeleted, String proofId)` 是服务端内部入口。TASK_3须先经平台签名的FC任务端点读取当前快照并核验task/consumer/school/form/answer/version/grant;proofId仅为审计引用,不是签名或浏览器可提交的授权凭据。使用现有fc.canon.v1复算hash,并绕过MQ授权缓存重新核验订阅且grantVersion必须相等。
- expectedRevision/hash/isDeleted是本地读取的完整三元组;全部null仅表示观察到缺失。已有行用单条数据库条件更新检查三元组与school/form/answer仍匹配,更新仅限快照列与修复留证,保留bizExt/intent/apply/flow;缺失行插入UNLINKED投影。修复不调用建件、不写inbox或消费回执、不改意图。唯一键竞争须回滚并重新读取。
- SH投影新增本地 `projection_version` 乐观锁列(与FC revision无关);权威条件更新同时递增该列,已读取旧实体的MQ/收据写入将触发乐观锁冲突,不能把权威修复覆盖回旧值。冲突必须整笔回滚,在事务外重新读取后有界重试。已有TASK_2库先执行 `svc-hall/server/sql/add_fc_projection_version.sql`,新库直接使用更新后的 `fc_projection_and_apply_intent.sql`,两脚本不可重复套用;本次未执行数据库升级。
- 权威修复信封eventId最多36字符,可用任务/答卷操作的UUID;proofId最多128字符。新鲜授权响应必须同时匹配form/consumer/school及相等grantVersion,不能用别校/别应用响应或未来纪元放行。
- 稳定返回:`APPLIED`、`LOCAL_CHANGED`(期望已变化,重新读取后有界重试)、`LOCAL_AHEAD`(本地revision更高,向FC复核且不降级)、`DEFERRED`(新鲜授权核验不通过,不写入)。同revision异hash仅在此权威入口可修复,普通MQ规则不变。
- `ManifestPage manifestPage(Long schId, String formUuid, String cursor, Integer size)` 使用answerUuid升序keyset,初始cursor=null,按返回nextCursor继续;size默认200/上限500,多读一条确定completed。返回 `{rows,nextCursor,completed}`;rows为answerUuid/revision/contentHash/isDeleted/schId/createTime,结束时completed=true且nextCursor=null,不暴露库内id。
- 本地分页不提供一致性快照证明。TASK_3须保存扫描结果/检查点,并用完整FC物化manifest、新鲜缺席证明及任务起点校验退役候选;本地createTime为空或晚于任务起点时不可退役。分页中途新增/变动不得被“没看到”解释为中心不存在。
- 新增规则门禁 `ShReconcileExtensionTest`;MySQL实际条件更新/唯一键并发、签名任务端点及Rabbit运行期行为仍需联调验证,规则单测不替代它们。
## 9. 待修清单(本轮刻意不修;跨校那条必须在本任务联调验收前解决)
1. **跨校授权缺"调用方属于该租户域"的事实源(TASK_1 遗留,优先级最高)**。
`FormConsumerAccessService` 判的是"(formUuid, consumerSchoolId, consumerAppKey) 三元键上有没有生效
grant",而 `consumerSchoolId` 一直是**调用方申报**的:grant 创建侧只限制"校管理员只能授给本校",
超管可代任意校授,于是"这个 appKey 到底能不能在 school=6 下消费数据"没有第二重证明。
本任务的可信建件把这条链放大成写侧风险——SH 申报的是事项所在校,中心照单核。
修向(择一,需授权):FC 侧建立 appKey↔可服务学校集合的事实源(UserBase 注册信息),
或把 grant 创建限定为"消费方应用注册校"。**本轮按边界要求不改 TASK_1 的 ACL/目录文件**。
2. **托管填报的资格口径两侧不同**:SH 入口判"事项发布 + 在线办理 + FC 绑定",FC 仍按表单
`audience` 门判"这个会话身份能不能填这张表"。若中心侧表单人群配得比事项服务对象严
(例如只含教师),学生会话会出现"能打开被嵌页、提交被 403"。修向:要么 SH 入口镜像 audience,
要么 hosted 通道把资格判据换成"意图授权"。本轮按任务书口径**保留 audience 门不动**
(它同时是 FC 自用通道的判据,改它要单独授权)。
3. **v1 与 v2 的事件口径分叉**:改业务状态现在只产生 v2 的 `updated` 投递,
v1 `fc.events.v1` 仍不发状态变更事件(v1 的 `type` 闭集与消费方约定都在冻结范围外)。
需要显式决定"v1 冻结并声明"还是"v1 同步补发",否则仍依赖 v1 的旧消费方看不到 status 变化。
4. **应急对账闸不认识新命令表**:`FlowReconcileJob` 仍只看 `service_apply.flow_status`,
不知道 `flow_apply_command` 的 `UNKNOWN` 行(该文件构造器被基线测试逐位钉住,本轮不动)。
大面积事故时开闸对账与恢复扫描可能同时推进同一条链路,需要后续统一到一个处置出口。
5. **回调"更高版本 ⇒ 新一轮"的判定还没与 attempt 映射对齐**(任务书 C.4 后半句,本轮未做):
`SvcFlowCallbackHandler#advance` 仍按 `instanceVersion > last_instance_version` 认定新轮,
而本轮新增的持久轮次在 `flow_apply_command.attempt` 上,两侧没有交叉核对。
当前实际影响有限(R4 重启只由用户显式重提触发,且 `markResubmitted` 会清空旧轮结果),
但"版本更高"在严格口径下**不自动证明**是新一轮——需要回调侧按 intent/attempt 映射复核后再放行。
本轮按"不顺手改既有接收器"处理,留待与 §9#4 一并在统一处置出口里做。
## 10. 未实现范围与证据边界(照实说)
- **问卷类独立后端未定位**:本文 §3~§7 是可直接执行的消费协议与适配要求(信封、canonicalization、
inbox/投影唯一键、回执、撤权竞态)。**未宣称"三端之外所有消费者已接入"**,也不虚构该消费端源码。
- **部门实施冻结**:新办件首次发起只做既有规则的成员就绪检查;部门来源切换、未来节点
冻结/改派/挂起、当前科室停用行为细则**不在本任务**(按已知限制记录,不声称实现 D10 全部细则)。
- 前端只做到**构建级验证**(`vite build` 两条绿),本机浏览器通道不可用 ⇒ 没有 iframe 视觉证据;
"消息来源三重校验(精确 origin + `event.source` + proto/v)"目前只有代码与协议级依据。
- 测试是**规则单测**(FC 21 / SH 26 / FL 12 条,全绿)。桩事务能证"答卷写与 outbox 写在同一事务回调内、
回调抛出后两者都不落地、afterCommit 只唤醒扫描",**证不了 MySQL 的原子性**;
manual ack、死信路由、publisher confirm 的 broker 行为在本机无 rabbit 的情况下未跑通。
这两类都要在联调环境补做,才算 §6/§7 的完整验收。
- DDL 三个文件(`form-center/service/sql/answer_delivery_v2.sql`、
`svc-hall/server/sql/fc_projection_and_apply_intent.sql`、
`flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql`)
都是**非幂等**脚本,生产需人工执行;`ddl-auto=update` 的开发库会自动追加,两侧不能长期分叉。
+364
View File
@@ -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 配置」 |
+117
View File
@@ -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 绑定**仅超管**可设——否则校管理员可以把平台资源认领给自己应用;
- **统一拒绝口径**:跨校 / 缺归属 / 不存在 / 未授权 → 同一句 `表单不存在:<uuid>`,真实原因只进服务端 warn 日志;
`answer` 端点被拒时回答 `答卷不存在:<answerId>`(答"表单不存在"等于同时确认两张对象都存在);
- **撤销不删行**:`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<SubscriptionGrant> listEnabledSubscriptions(String formUuid, Long answerSchoolId)`
TASK_2(Apply 输入换轨)与 TASK_3(事件投递)只调这四个接口;`SubscriptionGrant.grantVersion` 必须随投递下发,
消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。**
+145
View File
@@ -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:<code>`。字段valueSchema由读接口生成,不属于设计写入白名单。visible_when、matrix及任意脚本属性都不是已实现契约。
FC / 同文件 / `FormSchemaValidator#PHASE1_TYPES`:
```java
public static final Set<String> 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支持。
+105
View File
@@ -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;③<b>不建物理外键</b>——answer_uuid / form_uuid 都是逻辑引用
-- (答卷无物理删除但表单可软删,投递行与上下文必须能在其之后继续存在以支撑溯源);
-- ④唯一键里的 answer_revision 是 BIGINT,不占 utf8mb4 长度预算。
-- 身份口径:consumer_app_key / source_app_key 存<b>平台 appKey</b>(UserBase 注册分配、
-- 验签三件套里的 X-App-Key),<b>不是</b> form.app_code 那种人读 slug——
-- 资源 appCode 当消费者会把"同应用多消费方"合并成一行(任务书 B.3 点名禁止)。
-- 双时刻纪律:broker_accepted_at 与 consumer_applied_at 是两列、两个写入方,
-- 前者只由 confirm 回调写、后者只由签名回执端点写;任何一侧都不许替另一侧作证(B.4/B.6)。
-- 存量:answer.answer_revision 默认 0,<b>不回填历史版本、不建双轨</b>(任务书 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 '投递元数据:重试计数<b>不进快照、不参与哈希</b>',
`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(<b>明文不入库</b>,只在签发响应里出现一次)',
`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(不回填历史)
+128
View File
@@ -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;
-- ③<b>不建物理外键</b>——task_uuid 是逻辑引用,清单与结果要能在任务行之后继续存在以支撑复盘;
-- ④清单游标与本地扫描游标同为 answer_uuid(32 位小写十六进制),general_ci 下与 binary 序一致。
-- 身份口径:consumer_app_key 存平台 appKey(验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug。
-- 审计纪律:三张表都<b>没有删除路径</b>(服务层与仓库层都不提供 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(<b>不是</b> 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 '清单结束标记:<b>只有它为 1</b>,"清单里没有"才可能是权威缺席',
`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 收下指令的时刻(<b>不代表执行完成</b>,终态只看 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 '回报来源:<b>必须等于已验签身份</b>,自报不符即拒',
`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 四坐标,<b>不含 values</b>)',
`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 行(值文档与密钥不进对账留痕)
+64
View File
@@ -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;
-- ③<b>不建物理外键</b>——form_uuid 对 form 是逻辑引用(同 t16 对 app_code 的处理:
-- 表单软删是唯一切除形态且无物理删除,绑定/授权行必须能在表单软删后继续存在以支撑溯源,
-- 建 FK 等于把"级联删"写进库侧规则);④无可空业务列(判权路径不写"未知=怎样"的分支)。
-- 身份口径:owner_app_key / consumer_app_key 存的是<b>平台 appKey</b>(UserBase 应用注册分配,
-- 验签三件套里那个 X-App-Key),<b>不是</b> form.app_code 那种人读 slug。
-- app_registry 仍是快应用目录,本批一行不加(v3 §1 红线:不把业务授权塞回注册表)。
-- 租户口径:consumer_school_id 既是授权生效域,也是读答卷时的行集限定域;0 不是合法消费域
-- (平台级是资源域,不是一个能答卷的租户),服务侧直接拒。
-- 默认拒绝:两把 *_allowed 开关默认 0,enabled 默认 1(建行即生效,但建之前服务侧必须显式给开关);
-- 撤销是 enabled=0 + grant_version 进位,<b>不删行</b>(判权 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 行(无物理外键)
@@ -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)。
*
* <p>{@code @EnableScheduling} 落在本类而不是应用主类:{@code FormCenterApplication} 不在本任务
* 写范围内,而 v2 的退避重投必须有周期扫描(broker 恢复后要把 PENDING/FAILED 追平)。</p>
*
* <p>扫描<b>不是</b>持久性机制——持久性是 outbox 行本身(同事务落库)。这里只是把"到点的行"
* 推给 broker;{@code afterCommit} 的即时唤醒({@link AnswerDeliveryService#wakeScan()})负责低延迟,
* 本方法负责兜底。未配 broker 时 {@code sendDueBatchQuietly} 直接返回 0,行留在库里。</p>
*/
@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);
}
}
}
@@ -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)。
*
* <p><b>本任务的配置全部独立</b>:键前缀 {@code fc.answer.reconcile.*},不写 {@code fc.answer.delivery.*}
* (TASK_2)、不写 {@code form-center.*}(TASK_1)的键,也不改那两个任务的任何文件。
* {@code @EnableScheduling} 早已由 {@link AnswerDeliveryConfiguration} 打开,本类只加自己的闹钟。</p>
*
* <p>两个开关默认都是<b>关</b>的:{@code schedule-enabled}(每日建任务)与 {@code dispatch-enabled}
* (物化+派发循环)。未授权订阅不产生任务,而未开调度同样不产生任务——调度可显式关闭这件事
* 是任务书的前置条件,不是可选优化。 {@code targets} 是<b>服务端登记</b>的消费方后台地址表,
* 键=已验签的 consumerAppKey:派发地址只能从这里来,浏览器/请求体里出现的任何 callbackUrl
* 在本任务里没有对应字段,也就无从采信。</p>
*/
@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<String, String> 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;
}
}
@@ -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)。
*
* <pre>
* 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 进位)
* </pre>
*
* <p>守卫沿用既有那把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③,
* 与 app 注册、管理员两表同域),不新造一套管理面鉴权。为什么不是"创建人也能改":
* owner/grant 决定的是<b>别的能不能读这张表</b>,属于平台管理域而不是单张表的定义权——
* 创建人能把表建起来、发出去,但不能替全校做"把答卷给外部应用看"这个决定。</p>
*
* <p><b>身份核验的正向路径</b>(v3 §2):写进来的 appKey 必须是 UserBase 注册过的应用
* ({@link ConsumerPrincipalResolver#isRegisteredAppKey} 查平台密钥注册表,只判存在、不回显密钥)。
* 签名应用没有自报 owner 的通道——这张表上没有"应用自己声明我拥有某张表单"的入口。</p>
*/
@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<GrantView> 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");
}
}
}
@@ -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/**})。
*
* <p>守卫与 grant 管理同一条:{@code requirePlatformAdmin}(校管理员 ∪ 超管),
* 校级管理员的可见与可操作范围都钉在本校域内(跨域寻址按"任务不存在"回答)。
* 这里<b>没有</b>任何"批量重发全部答卷"的入口——全量只能建对账任务由 Worker 逐差异修复,
* 单答卷才允许直接重放投递({@code replayAuthorized}),且执行前重新过一遍 ACL。</p>
*
* <p>旧 outbox 日志不等于新答卷:{@code /deliveries/failed} 只是投递侧的失败/死信查看,
* 真正的差异修复走 {@code /tasks},两者不互相证明。</p>
*/
@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<TaskView> 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<TaskView> 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<ResultView> 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);
}
}
@@ -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):被业务应用嵌入时的取址与提交。
*
* <p>与中心自用填报页的三点不同,都在"钉版"这件事上:</p>
* <ul>
* <li><b>按上下文令牌取 schema</b>,坐标是签发时锁定的 {@code formUuid+formVersion},
* <b>永不回落到当前发布版</b>——升版窗口里在途的办件必须按当时那一版填,
* 否则收上来的值与办件绑定的坐标就不是同一张表;</li>
* <li><b>身份仍由 CAS 取</b>:令牌只说"这次填报属于哪个意图",
* {@code schId/submitterId} 一律会话推导,且与令牌断言的人/校逐字比对(错人错校即拒);</li>
* <li><b>首批只允许登录本人</b>:匿名与普通提交仍是 FC 自用的两条既有通道,
* 匿名答卷进不了这条链({@code submitterId} 对不上哨兵 0,见
* {@code SubmissionContextService#redeem})。</li>
* </ul>
*
* <p>提交走 {@code POST /api/form/hosted/submit},请求体与 {@code /api/form/submit} 同形
* (多带一个 {@code contextToken});管线仍是 {@code AnswerService} 那一条,
* hosted 只是"多一道令牌复核 + 原子关联意图",不开第二份写库实现。</p>
*/
@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);
}
}
@@ -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 + 撤权边界)。
*
* <p>两条都是<b>签名 HTTP</b>,与 MQ 确认是两回事:</p>
* <ul>
* <li>{@code POST /oapi/form/answer-delivery/receipt}——消费方业务落库后的结果。
* broker 的 confirm 只证明总线收下,绝不能代替它(B.4/B.6);{@code consumerAppKey}
* 以<b>验签身份</b>为准,请求体里自报的值不一致即拒;</li>
* <li>{@code POST /oapi/form/answer-delivery/grants/verify}——轻量授权核验:按 formUuid 批量
* 回 {@code subscribeAllowed + grantVersion},<b>不回查任何答案值文档</b>。消费端带 TTL 缓存它,
* 缓存命中期间撤权的窗口由这条接口收敛;无法确认时消费端必须暂缓写入(v2 §5)。
* 判据仍然是 TASK_1 的 {@code requireSubscribe},这里不拼第二套 ACL。</li>
* </ul>
*
* <p>路径在 {@code /oapi/*} 下,由既有 {@code RequireAppSignFilter} 强制应用签名。</p>
*/
@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<GrantSnapshotView> 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));
}
}
@@ -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)。
*
* <p>三条端点<b>成对</b>于 svc-hall 侧的 Worker:清单分页、任务快照答复、结果回报。
* 与 {@code OapiFormController} 隔离——那边是"按游标增量拉答卷"的数据面,
* 这一边是"某一轮对账的取证与回报",鉴权语境完全不同(这里必须核任务坐标与最新 grant)。</p>
*
* <p>身份唯一来源仍是平台验签后写在 {@code yh.app.auth} 上的 appKey;
* 请求里申报的任何 consumerAppKey 都不采信,冒充任务目标即 {@code 403}。
* 每次读取都过一遍 {@code AnswerReconcileService} 的核验:任务存在、身份等于目标、
* 未取消、最新 grant 仍生效且纪元未进位——不因"建任务时有权"永久放行。</p>
*/
@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);
}
/**
* 任务快照答复:修复入口的唯一权威来源。
*
* <p>{@code expectedRevision} 只在"中心清单里没有这份答卷"时用于把缺席证明钉在消费端扫描到的
* 那一版上(消费端并发护栏);存在性结论与快照内容都来自中心当前值。</p>
*/
@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));
}
}
@@ -1,25 +1,37 @@
package com.yunhai.formcenter.controller.oapi; 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.model.OapiModels;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.OapiFormService; import com.yunhai.formcenter.service.OapiFormService;
import com.yunhai.platform.appauth.AppAuthFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/** /**
* 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的 * 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的
* {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。 * {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。
* *
* <pre> * <pre>
* GET /oapi/form/schema?schId=&amp;code=[&amp;appCode=] → 现形态:data=内联 options + valueSchema 的机读 schema(契约 §4) * GET /oapi/form/forms?schId=&amp;appCode=&amp;keyword=&amp;page=&amp;pageSize= → v3 目录(已发布 ∩ 可读,先过滤再分页)
* GET /oapi/form/schema?schId=&amp;code=[&amp;appCode=] → 现形态:内联 options + valueSchema 的机读 schema(契约 §4)
* GET /oapi/form/schema?schId=&amp;uuid= → T11 新形态:该表单<b>当前已发布版</b> * GET /oapi/form/schema?schId=&amp;uuid= → T11 新形态:该表单<b>当前已发布版</b>
* GET /oapi/form/schema?schId=&amp;uuid=&amp;version= → T11 新形态:精确版本行(快照核对/重放) * GET /oapi/form/schema?schId=&amp;uuid=&amp;version= → T11 新形态:精确版本行(快照核对/重放)
* GET /oapi/form/answer?schId=&amp;answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9) * GET /oapi/form/answer?schId=&amp;answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9)
* GET /oapi/form/answers?schId=&amp;form_uuid=&amp;游标 → v2 增量拉取(契约 v2 §6.1,T17,见方法注释) * GET /oapi/form/answers?schId=&amp;form_uuid=&amp;游标 → v2 增量拉取(契约 v2 §6.1,T17,见方法注释)
* </pre> * </pre>
* *
* <p><b>签名只给身份,不给权限</b>(v3 §1/§3):本控制器先取平台验签后落在请求属性
* {@code yh.app.auth} 上的 appKey({@link ConsumerPrincipalResolver}),再交给服务层过
* {@code FormConsumerAccessService.requireRead}。查询参数 {@code appCode} 与 {@code schId} 一样
* 只是资源筛选/租户域,任何"自报应用"的写法都不构成授权。</p>
*
* <p>uuid 两形态的用途=给 flow-center 在<b>流程定义发布期</b>拉契约快照嵌进 def 版本行 * <p>uuid 两形态的用途=给 flow-center 在<b>流程定义发布期</b>拉契约快照嵌进 def 版本行
* (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。 * (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。
* <b>uuid 与 code 同传时 uuid 优先</b>(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口, * <b>uuid 与 code 同传时 uuid 优先</b>(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口,
@@ -38,45 +50,72 @@ import org.springframework.web.bind.annotation.RestController;
public class OapiFormController { public class OapiFormController {
private final OapiFormService formService; private final OapiFormService formService;
private final ConsumerPrincipalResolver callerResolver;
/** /**
* 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态, * 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态,
* <b>同传时 uuid 优先</b>(见类头),{@code version} 只在 uuid 形态有意义=精确取版本行。 * <b>同传时 uuid 优先</b>(见类头),{@code version} 只在 uuid 形态有意义=精确取版本行。
*/ */
@GetMapping("/schema") @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 = "code", required = false) String code,
@RequestParam(value = "appCode", required = false) String appCode, @RequestParam(value = "appCode", required = false) String appCode,
@RequestParam(value = "uuid", required = false) String uuid, @RequestParam(value = "uuid", required = false) String uuid,
@RequestParam(value = "version", required = false) Integer version) { @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 引用 + 值文档) */ /** 单条答卷(钉住版本 schema 引用 + 值文档) */
@GetMapping("/answer") @GetMapping("/answer")
public OapiModels.AnswerView answer(@RequestParam("schId") Long schId, public OapiModels.AnswerView answer(HttpServletRequest request,
@RequestParam("schId") Long schId,
@RequestParam("answerId") String answerId) { @RequestParam("answerId") String answerId) {
return formService.answer(schId, answerId); return formService.answer(caller(request), schId, answerId);
} }
/** /**
* v2 增量拉取(契约 §6.1/C9,T17)——<b>事实源</b>,MQ 事件只是降延迟。 * v2 增量拉取(契约 §6.1/C9,T17)——<b>事实源</b>,MQ 事件只是降延迟。
* *
* <pre> * <pre>
* GET /oapi/form/answers?schId=&amp;formUuid=&amp;after=answer_uuid[&amp;limit=] 冻结表单:append-only + 墓碑 * GET /oapi/form/answers?schId=&amp;form_uuid=&amp;after=answer_uuid[&amp;limit=] 冻结表单:append-only + 墓碑
* GET /oapi/form/answers?schId=&amp;formUuid=&amp;updated_since=ISO8601[|answer_uuid] 非冻结:updated 窗口 * GET /oapi/form/answers?schId=&amp;form_uuid=&amp;updated_since=ISO8601[|answer_uuid] 非冻结:updated 窗口
* </pre> * </pre>
* *
* <p>两种游标按表单的冻结状态二分、不接受混用(拒绝文案点明这张表要哪种),{@code limit} 默认 500 * <p>两种游标按表单的冻结状态二分、不接受混用(拒绝文案点明这张表要哪种),{@code limit} 默认 500
* 上限 2000;响应 {@code {rows,nextCursor}},行字段是闭集且<b>不含库内 id</b>(V53)。 * 上限 2000;响应 {@code {rows,nextCursor}},行字段是闭集且<b>不含库内 id</b>(V53)。
* {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看)。</p> * {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看,
* v3 起还要另过 read 授权)。</p>
*/ */
@GetMapping("/answers") @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("form_uuid") String formUuid,
@RequestParam(value = "after", required = false) String after, @RequestParam(value = "after", required = false) String after,
@RequestParam(value = "updated_since", required = false) String updatedSince, @RequestParam(value = "updated_since", required = false) String updatedSince,
@RequestParam(value = "limit", required = false) Integer limit) { @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}}。
*
* <p>服务端过滤链=已发布 ∩ 未软删 ∩ 本校或平台级 ∩ 调用方可读,<b>过滤完才分页</b>
* (先分页后过滤会让 {@code total} 与页数随授权抖动)。{@code pageSize} 上限 100。</p>
*/
@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));
} }
} }
@@ -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)。
*
* <p><b>独立控制器、独立 DTO</b>:任务书明令不改 TASK_1 的 {@code OapiFormController/OapiModels},
* 上下文与收据这条链全部落在这里({@link com.yunhai.formcenter.model.AnswerDeliveryModels})。
* 路径仍在 {@code /oapi/*} 之下,由既有 {@code RequireAppSignFilter} 强制应用签名,
* 调用方身份只取验签后的 {@code yh.app.auth}——请求体里没有任何"自报应用"的字段。</p>
*
* <pre>
* POST /oapi/form/submission-context → 签发/复用上下文(同意图不新建第二条业务意图)
* GET /oapi/form/submission-context/{intentId}/receipt → 收据(只向所属消费应用返回)
* </pre>
*/
@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));
}
}
@@ -139,4 +139,22 @@ public class Answer extends BaseEntity {
/** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */ /** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0; private Integer isDeleted = 0;
/**
* 对外快照修订号(v2 投递契约 §2,TASK_2 B.1):<b>单调递增</b>,与 schema 的
* {@code version}(钉版坐标,{@code updatable=false})是两回事——前者说"这份数据又被改过一次",
* 后者说"它是按哪一版结构填的"。
*
* <p>凡是会改变<b>对外答卷快照</b>的路径都在同一事务里 +1 并写不可变 outbox:
* 创建、本人/管理员值编辑、改业务状态(含批量)、软删(含批量)。刻意不把
* {@code answer_index} 重建、留痕行这类"内部副作用"算作 revision 变更点之外的口径——
* 判据只有一条:<b>消费方看到的字节变了吗</b>。</p>
*
* <p>并发控制=行级 CAS({@code AnswerRepository#compareAndSetRevision},UPDATE 自身拿行锁,
* 抢不到号即 0 行→回滚)+ outbox 的 {@code uk_delivery_revision} 唯一键兜底。
* 没上 JPA {@code @Version}:那会让存量分离实体 {@code save} 路径(并发 IT 与批量导入)
* 突然开始抛 OptimisticLockException,属于跨批次的行为变更,不在本任务授权范围内。</p>
*/
@Column(name = "answer_revision", nullable = false)
private Long answerRevision = 0L;
} }
@@ -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)。
*
* <p><b>它是持久性载体,不是发送日志</b>:行必须在答卷<b>同一事务</b>里落库(旧
* {@code FcEventPublisher} 的 afterCommit 只负责"发",进程在提交与发送之间被杀就永久丢事件——
* 契约 v2 那时可以用"拉取即事实源"兜底,v2 快照投递不行,因为它要驱动对端的办件)。
* afterCommit 从此只剩一个作用:唤醒扫描。</p>
*
* <p><b>唯一键 {@code uk_delivery_revision} 是"同一 revision 不能对应两份不同 hash"的库级形态</b>:
* 两个并发写事务都读到 revision=N-1、各自算出 N 时,只有一个能插入成功,另一个整事务回滚
* (答卷与 outbox 同行消失),不存在"两条同版不同内容的投递行"。快照内容因此不可重建历史版本,
* 每行自带 {@code snapshot_json}——{@code intent} 关联块只对单个 consumer 可见,
* 各行的载荷本来就不同,不做跨行共享。</p>
*
* <p>{@code state} 只描述<b>投递</b>,不描述业务:{@code BROKER_ACCEPTED} 只代表总线收下
* (B.4 明令不得写成消费成功),消费方是否真的落库要看 {@code consumer_applied_at} /
* {@code consumer_outcome},那两列只由签名回执端点写。</p>
*/
@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(<b>不是</b>资源 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;
}
@@ -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)。
*
* <p><b>本任务里唯一充当"不可变清单"的东西</b>:在一致读事务内物化
* {@code (answerUuid, revision, contentHash, isDeleted)} 四元摘要(含墓碑),
* 之后所有分页只读这张表,不再回 {@code answer} 表。清单因此不会被物化之后发生的写入改动,
* "这一页空了"与"这个集合到底了"是两件事——后者只能由 {@code manifestComplete} 表达。</p>
*
* <p>键序按 {@code answer_uuid} 升序(32 位小写十六进制),与消费端本地 keyset 扫描同序,
* 两侧能用同一个游标语义锁步推进。哈希取 {@code AnswerSnapshotService} 的
* {@code fc.canon.v1} 口径,与投递行同一条规范化实现——本类不复制任何规范化逻辑。</p>
*
* <p>{@code formVersion} 取物化时该答卷钉住的 schema 版本(不是表单当前版本,也不从旧清单猜):
* 修复信封的 {@code formVersion} 必须由中心已核验的坐标给出,这一列就是那个坐标的留痕。</p>
*/
@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;
}
@@ -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):消费端一次执行的<b>可追踪事实</b>。
*
* <p>唯一键 {@code (task_id, attempt)} 是幂等锚点:同一 attempt 重复上报直接吸收,
* 比已记录 attempt 更旧的回报只留档、不推进任务状态("不让旧 attempt 回报倒退新 attempt")。
* 一行=一个 attempt,重派产生的新 attempt 是另一行,历史不被覆盖。</p>
*
* <p>计数都是<b>有上限</b>的摘要:明细只留在 {@code errors_json}(截断到此列长度),
* 答卷的 values 一律不进这里,也不进日志(§8 敏感数据纪律)。</p>
*
* <p>状态语义与任务终态一致:{@code COMPLETED}=两侧集合与摘要在完整扫描下无未决差异,
* {@code PARTIAL}=扫描或修复有未决项(差异仍在,但任务本身跑完了),
* {@code FAILED}/{@code CANCELLED}=执行中断。两者不能混为一谈。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>它是任务,不是投递行、不是答卷、也不是 {@code answer_op_log}</b>:一行=一个
* {@code (消费应用, 消费租户域, 表单)} 坐标上的一次对账企图。投递行({@link AnswerDelivery})
* 描述"这份快照有没有发出去",本行描述"这两边的集合差在哪、修复回报过没有",两者不能互相代替。</p>
*
* <p>任务坐标只到表单,不再宽到"全部表单":<b>全量=按生效订阅逐张表 fission 成多行任务</b>
* ({@code idempotencyKey} 里的日期+订阅键保证同一天同一订阅只建一次)。这样每一行的
* manifest 都能用单一 {@code answerUuid} 游标分页,与消费端本地 keyset 扫描同序,
* 两侧可以锁步比较——把"跨表单复合游标"这种一定会写错的东西从设计里删掉。</p>
*
* <p>派发纪律:{@code leaseUntil} 是本应用持有派发权的期限,超期由扫描按 {@code attempt+1}
* 有限退避重派;{@code dispatchedAt} 只说明"对端 2xx 收下了指令",
* <b>绝不等于</b>对端做完——终态只能由 {@link AnswerReconcileResult} 的回报推进。</p>
*/
@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);
}
}
@@ -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)。
*
* <p><b>三元唯一</b>:{@code (form_uuid, consumer_school_id, consumer_app_key)}——授权是
* "这张表允许这个租户的这个应用读/订阅",租户与应用缺一不可。只按 appKey 授权会丢掉租户维度,
* 而"读答卷仍以该校行集限定"(v3 §2)正是靠 {@code consumer_school_id} 落型的:
* 平台级表单({@code form.sch_id=0})对某校授予后,答卷行集依旧按那一校限定。</p>
*
* <p><b>默认拒绝</b>:读与订阅是分开的两把开关,都没有隐式全集;owner 应用天然有读权,
* 但订阅同样要显式开一行(读/写分权是这一版的红线)。撤销不删行,改 {@code enabled=0}
* 并让 {@code grantVersion} 进位——留着行才答得出"什么时候撤的权"。</p>
*
* <p>{@code grantVersion} 是给 TASK_3 事件面用的:订阅者必须带回版本号,消费方才能识别
* "这条推送用的授权已经作废",不允许那边再从库里自己拼一套授权判定。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>它只回答一件事</b>:这张表单(按全局 uuid)是哪个平台应用(appKey)的资产。
* 拥有者与消费租户是两件事——{@code owner_app_key} 是资产归属,"别的应用能不能读它"一律走
* {@link FormConsumerGrant},所以 owner 与 appCode 之间没有任何推导关系:
* 客户端自报的 {@code appCode} 只能筛资源,永远不授予拥有权(v3 §1)。</p>
*
* <p><b>owner 由谁写</b>:只有管理面(校管理员/超管,见 {@code FormAccessController})能设置,
* 且写入前用平台密钥注册表核验过 appKey 确实在 UserBase 注册存在;签名应用不能自报 owner。
* 缺归属行=没有拥有者,除显式 grant 外一律拒(fail-closed,默认拒绝)。</p>
*
* <p>{@code form_school_id} 存的是绑定时表单自己的 {@code sch_id}(0=平台级),
* 作为"这条绑定属于哪个资源域"的留痕:管理面的域判定用它复核,防止日后表单换校
* 让一条跨校绑定看起来仍然合法。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>它解决的是"答卷到底是不是这次业务意图产生的"这一个事实</b>:办事大厅先建意图、
* 再让被嵌页填报,中心必须能把答卷钉回那次意图,而不是相信浏览器带来的 bizId/answerId。
* 三条形状因此都是必需的:</p>
* <ul>
* <li><b>只存 token 摘要</b>:明文 {@code contextToken} 只在签发响应里出现一次;
* 库被读也不能拿它去兑换别人的填报(防"拖库即冒充");</li>
* <li><b>唯一键 (consumer_app_key, intent_id)</b>:同意图重试返回<b>同一</b>有效上下文,
* 结构上不允许出现"第二个业务意图"(A.2);</li>
* <li><b>唯一键 answer_uuid</b>:一份答卷只能原子关联到一个意图,
* "同 token 同幂等键返回原答卷、不同内容重用拒绝"在库级就有兜底(A.3)。</li>
* </ul>
*
* <p>{@code embed_origin}/{@code callback_url} 由<b>服务端配置/注册信息</b>填写(签发时落定),
* 请求体里没有这两个字段——浏览器给的任何 URL 都不采信(A.2 末句)。
* {@code submitter_id} 是消费方服务端断言的填报人,中心在 redeem 时与 CAS 会话逐字复核,
* 对不上就是 {@code FORBIDDEN}(token 错人)。</p>
*/
@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;
}
@@ -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)。
*
* <p>三条形状纪律:</p>
* <ul>
* <li><b>信封字段闭集</b>:{@link SnapshotEnvelope} 逐字段标 {@link JsonProperty}——
* 契约里写的是 camelCase,靠命名策略碰巧对上是脆弱的,多一个字段就该被当成违约(同 v1 §6.2 的
* {@code FcEventPublisherTest} 立场);</li>
* <li><b>身份不进消费方请求</b>:{@link ContextIssueRequest} 里没有 appKey/consumer 身份位,
* 调用方身份只取验签后的 {@code yh.app.auth}(v3 §1,TASK_1 已定的口径);
* 浏览器侧更不会出现在这条链上(这是服务端到服务端的签名调用);</li>
* <li><b>回执与投递状态分开</b>:{@code brokerAccepted}(总线收下)与
* {@code consumerApplied}(消费方业务落库并回执)是两个时刻、两组列,
* 谁也不能替谁作证(B.4/B.6)。</li>
* </ul>
*/
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<String> 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<FailedDeliveryView> 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<String> audience;
private Integer fillLimit;
private String deadline;
private Boolean deadlineReached;
private Object groups;
private String expiresAt;
/** 消费方 origin:填报页 postMessage 的<b>唯一允许目标</b>(精确到 scheme+host+port) */
private String embedOrigin;
}
}
@@ -37,6 +37,14 @@ public final class AnswerModels {
private String idempotencyKey; private String idempotencyKey;
/** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */ /** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */
private Integer subjectId; private Integer subjectId;
/**
* 可选:可信提交上下文令牌(v2 契约 §6.3,TASK_2 A.3)。带它的提交就是 hosted 链路——
* 中心在<b>答卷事务内</b>兑换令牌、按令牌锁定的 {@code formUuid+formVersion} 钉版、
* 复核会话用户/学校,并把答卷原子关联回消费方的业务意图。
* 它<b>不是</b>身份载体:{@code schId/submitterId} 仍只由会话推导(V17 不变),
* 令牌里断言的人与会话不符即拒;也没有 bizId 位——浏览器传不进办件坐标。
*/
private String contextToken;
/** 值文档 {@code {key:value}},形状见契约 §3 */ /** 值文档 {@code {key:value}},形状见契约 §3 */
private JsonNode values; private JsonNode values;
} }
@@ -0,0 +1,127 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 消费应用授权与目录(v3,TASK_1)的模型。
*
* <p>三条不外露的线:库内 {@code id}(契约 §1/V5 同口径,寻址一律 uuid + 三元键)、
* 任何 appSecret(授权判定只用平台密钥注册表验"这个 appKey 存不存在",绝不回显密钥)、
* 归属差别信息(跨校/缺归属/不存在同一句拒绝,见 {@code FormConsumerAccessService})。</p>
*/
public final class FormAccessModels {
private FormAccessModels() {
}
/**
* 已验签的消费应用身份。唯一字段是 {@code appKey}——它由平台 {@code AppAuthFilter} 验签通过后
* 写在请求属性 {@code yh.app.auth} 上,本应用只是转读,没有任何"客户端自报身份"的入口。
*
* <p>{@code equals} 是给单测的透传断言用的(controller 层"只传不改"这件事要能按值比对),
* 不是给业务判权用的。</p>
*/
@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<String> audience;
}
/** 目录分页载荷:{@code {items,total}},{@code total}=过滤后的可读集大小(不是库内行数) */
@Getter
@Setter
public static class CatalogPage {
private List<CatalogItem> 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;
}
}
@@ -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)。
*
* <p><b>独立 DTO</b>:不往 {@code OapiModels}(TASK_1)与 {@code AnswerDeliveryModels}(TASK_2)里塞字段,
* 那两个文件的形状由各自任务负责。唯一的跨任务共享类型是
* {@link AnswerDeliveryModels.SnapshotEnvelope}/{@link AnswerDeliveryModels.Snapshot}——
* 修复信封必须与投递信封同一条规范化口径,复制一份就给了哈希分叉机会。</p>
*
* <p>{@code values} 一律只出现在 {@code Snapshot} 里、由签名任务端点按授权下发;
* 本文件的<b>任何</b>回报结构都不带值文档(§8 敏感数据纪律:错误明细里也不许出现)。</p>
*/
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} 的请求体)。
*
* <p>只带<b>任务坐标与清单规模</b>,不带任何答卷数据;地址来自服务端登记的后台服务地址,
* 浏览器提供的 callbackUrl 在这里没有任何对应字段。</p>
*/
@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<ManifestRowView> rows = new ArrayList<>();
private String nextCursor;
private Boolean completed;
private Integer totalRows;
private Integer totalPages;
/** 任务当前状态(CANCELLED 时消费端须停止剩余修复) */
private String taskState;
}
/**
* 任务快照答复:修复入口唯一的权威来源。
*
* <p>{@code presence=PRESENT} 时带 {@code envelope}(服务端按已核验坐标构造,
* formUuid/formVersion 来自任务行与答卷行,不从旧清单猜);
* {@code ABSENT} 时带 {@code absenceProof};{@code UNKNOWN}(清单未完整、期间新增、
* 范围改变、撤权)时两者都不给,只给原因——消费端据此只能报告差异,不能破坏本地数据。</p>
*/
@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<ReconcileError> 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<ReconcileError> 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;
}
}
@@ -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)。
*
* <p>只提供扫描/定位/分页三类取数,<b>不提供任何删除方法</b>:投递行是审计资产,
* 终态(DEAD/GRANT_REVOKED)留在表里供对账与人工处置,与答卷"无物理删除"同一条纪律
* ({@code AnswerRepository} 类头)。</p>
*/
public interface AnswerDeliveryRepository extends JpaRepository<AnswerDelivery, Long> {
/** 回执端点定位(eventId 全局唯一,另三键做逐字复核) */
Optional<AnswerDelivery> findByEventId(String eventId);
/** 同 revision 同消费方是否已有行(入队幂等;正常由 uk_delivery_revision 兜底) */
Optional<AnswerDelivery> findByAnswerUuidAndAnswerRevisionAndConsumerAppKeyAndConsumerSchoolId(
String answerUuid, Long answerRevision, String consumerAppKey, Long consumerSchoolId);
List<AnswerDelivery> 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<AnswerDelivery> 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<AnswerDelivery> findFailedForConsumer(@Param("appKey") String appKey,
@Param("schoolId") Long schoolId, Pageable pageable);
/** 该答卷是否还有未落定投递(墓碑/回放前的存在性判定) */
boolean existsByAnswerUuidAndStateIn(String answerUuid, List<String> states);
@Query("select count(d) from AnswerDelivery d where d.state = :state")
long countByState(@Param("state") String state);
}
@@ -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)。
*
* <p>只读接口 + 批量插入:<b>没有 update、没有 delete</b>。清单一旦物化就是这一轮对账的事实源,
* 分页读它、缺席判定也读它;{@code answer} 表此后怎么变都不影响本轮结论(这正是它存在的理由)。</p>
*/
public interface AnswerReconcileManifestRepository extends JpaRepository<AnswerReconcileManifestRow, Long> {
/** 清单分页:游标=上一个 answerUuid(32 位十六进制),不是页码、不是库内 id */
@Query("select m from AnswerReconcileManifestRow m where m.taskId = :taskId and m.answerUuid > :cursor "
+ "order by m.answerUuid asc")
List<AnswerReconcileManifestRow> findPage(@Param("taskId") Long taskId, @Param("cursor") String cursor,
Pageable pageable);
Optional<AnswerReconcileManifestRow> 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);
}
@@ -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/投影")。
*
* <p>存在的理由有两层:</p>
* <ul>
* <li><b>不给 TASK_2 的文件加快捷查询</b>——{@code AnswerRepository} 是投递与数据面共用的门面,
* 对账要的"按 uuid 升序整表扫"只在物化时用一个瞬间,塞进共用门面就是污染;</li>
* <li><b>订阅清单的枚举口径归本任务</b>——TASK_1 的 {@code FormConsumerGrantRepository} 只提供
* 按表单取订阅(投递路径的形态),调度要的是"全校全表单的生效订阅游标",这是本任务的读取面,
* 条件仍与 TASK_1 逐字一致({@code enabled=1 and subscribeAllowed=1})。</li>
* </ul>
*
* <p>返回 {@code Answer} 实体而不是投影:清单摘要里的 hash 必须由
* {@code AnswerSnapshotService#contentHash} 按 {@code fc.canon.v1} 现算,而它要完整值文档。
* 取数一律带 keyset 游标与 {@code Pageable} 上限,<b>任何方法都不允许无界拉全表</b>。</p>
*/
public interface AnswerReconcileReadRepository extends JpaRepository<Answer, Long> {
/** 物化取数:按 (表单, 消费租户域) 的 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<Answer> 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<FormConsumerGrant> findLiveSubscriptionsAfterId(@Param("cursor") Long cursor, Pageable pageable);
}
@@ -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)。
*
* <p>一 attempt 一行、{@code (task_id, attempt)} 唯一:重复回报由服务层读回原行按幂等吸收,
* 不做覆盖更新;比最新 attempt 更旧的回报由 {@link #findLatest} 的返回值判方向后拒绝推进。</p>
*/
public interface AnswerReconcileResultRepository extends JpaRepository<AnswerReconcileResult, Long> {
Optional<AnswerReconcileResult> findByTaskIdAndAttempt(Long taskId, Integer attempt);
Optional<AnswerReconcileResult> 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<AnswerReconcileResult> 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<AnswerReconcileResult> findUnresolved(@Param("appKey") String appKey,
@Param("schoolId") Long schoolId, Pageable pageable);
}
@@ -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)。
*
* <p>三件事在这里定型:<b>①领取用条件更新而不是"读—改—写"</b>(两个扫描线程同时看到同一条
* QUEUED 时,只有一个能刷新租约,另一个更新 0 行即退出——这就是任务书要求的单并发);
* <b>②同一坐标只允许一个活跃任务</b>({@code countActiveAt} 是它的判据);
* <b>③没有任何删除方法</b>:任务、清单、结果都是审计资产,终态留表供复盘。</p>
*/
public interface AnswerReconcileTaskRepository extends JpaRepository<AnswerReconcileTask, Long> {
Optional<AnswerReconcileTask> findByTaskUuid(String taskUuid);
Optional<AnswerReconcileTask> findByIdempotencyKey(String idempotencyKey);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from AnswerReconcileTask t where t.id = :id")
Optional<AnswerReconcileTask> 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<AnswerReconcileTask> 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<AnswerReconcileTask> 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<AnswerReconcileTask> 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<AnswerReconcileTask> 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<AnswerReconcileTask> 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<AnswerReconcileTask> 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);
}
@@ -3,6 +3,7 @@ package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Answer;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -25,6 +26,21 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
Optional<Answer> findByUuidAndSchId(String uuid, Long schId); Optional<Answer> findByUuidAndSchId(String uuid, Long schId);
/**
* 快照修订号行级 CAS(v2 投递契约 §2,TASK_2 B.1 的并发控制落点)。
*
* <p>{@code UPDATE} 自身拿行锁:并发改同一条答卷时,后到的事务在这里被挡到前一个提交,
* 回来后 {@code WHERE answer_revision = :expected} 不再匹配 → 返回 0 行 → 调用方抛异常整事务回滚。
* 与 outbox 的 {@code uk_delivery_revision} 是同一条不变式的两道闸:CAS 挡"取到同一个号",
* 唯一键挡"已经写出去的两行同版不同内容"。</p>
*
* <p>必须在改动实体的 {@code answerRevision} 字段<b>之前</b>调用:JPQL 批量更新绕过脏检查,
* 先改字段会让自动 flush 把新号写进去、CAS 再也匹配不上旧号(自我拒绝)。</p>
*/
@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 档)。 * 按 (域, uuid 集合) 批量定位(T14 守卫:数据面入口要按答卷反查它所属的表单才能判 ALL 档)。
* 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。 * 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。
@@ -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} 的量级论证)。
*
* <p>撤销用 {@code enabled=0} 表达,所以<b>判授权的查询必须带 enabled 条件</b>——
* 漏掉它就会让已撤销的授权继续生效(fail-open),这条是本仓库唯一不能写错的地方。</p>
*/
public interface FormConsumerGrantRepository extends JpaRepository<FormConsumerGrant, Long> {
Optional<FormConsumerGrant> findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(
String formUuid, Long consumerSchoolId, String consumerAppKey);
/** 管理端 grant 清单(含已撤销行:撤销后仍要看得见、可重申) */
@Query("select g from FormConsumerGrant g where g.formUuid = :formUuid order by g.id asc")
List<FormConsumerGrant> 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<FormConsumerGrant> 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<FormConsumerGrant> findForConsumer(@Param("uuids") Collection<String> uuids,
@Param("schoolId") Long schoolId,
@Param("appKey") String appKey);
}
@@ -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<FormOwnership, Long> {
Optional<FormOwnership> findByFormUuid(String formUuid);
/** 目录端点的批量取绑定(避免逐张表 N+1 查询) */
@Query("select o from FormOwnership o where o.formUuid in (:uuids)")
List<FormOwnership> findByFormUuidIn(@Param("uuids") Collection<String> uuids);
}
@@ -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)。
*
* <p>定位方式只有三种,各自对应契约里的一条不变式:</p>
* <ul>
* <li>{@code (consumerAppKey, intentId)}——同意图重试必须回到<b>同一</b>上下文(uk_ctx_intent);</li>
* <li>{@code tokenDigest}——填报页 redeem,只按摘要查,明文不入库(uk_ctx_token_digest);</li>
* <li>{@code answerUuid}——收据端点按答卷反查意图(uk_ctx_answer 保证一对一)。</li>
* </ul>
*/
public interface SubmissionContextRepository extends JpaRepository<SubmissionContext, Long> {
Optional<SubmissionContext> findByConsumerAppKeyAndIntentId(String consumerAppKey, String intentId);
Optional<SubmissionContext> findByTokenDigest(String tokenDigest);
Optional<SubmissionContext> findByAnswerUuid(String answerUuid);
}
@@ -9,6 +9,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.entity.StatusLog; import com.yunhai.formcenter.entity.StatusLog;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository; import com.yunhai.formcenter.repository.AnswerOpLogRepository;
@@ -72,6 +73,8 @@ public class AnswerAdminService {
private final AnswerScopeQueryBuilder queryBuilder; private final AnswerScopeQueryBuilder queryBuilder;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final FcEventPublisher events; 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); assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson);
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
// 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变 // 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变
indexService.rebuild(answer, schemaJson); indexService.rebuild(answer, schemaJson);
// 事件:非冻结编辑(冻结在上面就拒了,走不到这里)= updated(契约 v2 §6.2 发布点之一) // 事件:非冻结编辑(冻结在上面就拒了,走不到这里)= updated(契约 v2 §6.2 发布点之一)
events.answerUpdated(answer); events.answerUpdated(answer);
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(), opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(),
req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")" req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")"
: "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary()); : "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary());
@@ -157,6 +162,7 @@ public class AnswerAdminService {
private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) { private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) {
Answer answer = requireManageableAnswer(operator, schId, answerUuid); Answer answer = requireManageableAnswer(operator, schId, answerUuid);
answer.setIsDeleted(1); answer.setIsDeleted(1);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
indexService.clear(answer.getId()); indexService.clear(answer.getId());
// 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。 // 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。
@@ -168,6 +174,8 @@ public class AnswerAdminService {
operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)"); operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
// 事件:tombstoned(管理面单条与批量共用本方法,所以两个入口一处发布,§6.2) // 事件:tombstoned(管理面单条与批量共用本方法,所以两个入口一处发布,§6.2)
events.answerTombstoned(answer); events.answerTombstoned(answer);
// 批量公共出口同样在每条独立事务里取号+入队:不存在"批量只改行不投快照"的旁路
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_TOMBSTONED);
} }
private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req, private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req,
@@ -188,6 +196,7 @@ public class AnswerAdminService {
} }
String oldStatus = answer.getStatus(); String oldStatus = answer.getStatus();
answer.setStatus(status); answer.setStatus(status);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
StatusLog entry = new StatusLog(); StatusLog entry = new StatusLog();
entry.setAnswerId(answer.getId()); entry.setAnswerId(answer.getId());
@@ -198,6 +207,9 @@ public class AnswerAdminService {
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS, opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS,
operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
+ "(无转移规则校验)"); + "(无转移规则校验)");
// 任务书点名的漏项防线:status 是对外快照成员,改状态必须同样取号并入队(updated),
// 否则"审核已通过"这类事实对消费方永远不可见,而 outbox 看上去还是完整的
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
} }
// ==================== 辅助 ==================== // ==================== 辅助 ====================
@@ -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)。
*
* <p><b>为什么要有版本化规范化,而不是直接对 {@code valuesJson} 原文求哈希</b>:投递侧要在
* "同一 revision 只能对应一份 hash"这条不变式上落库唯一键,消费侧要用<b>同一算法</b>复算来判
* "同版异 hash=冲突待核验"。成员顺序取决于来源(值文档是客户端 JSON 原文、身份列是服务端拼装),
* 不规范化就会让同一份数据产出两种字节串——那等于把幂等判据建在随机数上。</p>
*
* <p><b>{@code fc.canon.v1} 规则闭集</b>(要改语义就升算法号,不许原地改):</p>
* <ol>
* <li><b>对象</b>:键按 <b>UTF-16 代码元字典序</b>({@link String#compareTo})升序递归输出——
* 刻意钉比较器而不是"码点序":两端都是 JVM,同一个比较器才逐字一致;</li>
* <li><b>数组</b>:顺序保留(多选/排序是业务语义),逐元素递归;</li>
* <li><b>数字</b>:{@code decimalValue().stripTrailingZeros().toPlainString()},
* 即 {@code 100.0→100}、{@code 1E+2→100},无指数、无多余前导零;</li>
* <li><b>字符串</b>:仅转义 {@code " \ 与控制字符}({@code \b\f\n\r\t} 用短转义,
* 其余 C0 用"反斜杠 + u + 00 + 两位大写十六进制",非 ASCII(含中文)<b>原样输出</b>,
* 哈希按 UTF-8 取字节;</li>
* <li><b>空值策略</b>:<b>显式 null 成员保留</b>(缺失键与 {@code "k":null} 是两种事实,
* 合并会丢"字段被清空"这次变化);{@code missing}/{@code null} 节点视为不存在的成员;</li>
* <li><b>排除项</b>:快照顶层的 {@code intent} 成员不参与哈希——它是"仅对该 consumer 可见"的
* 关联块,同 revision 会因为消费方不同而不同,留着就与不变式①直接矛盾;
* 投递时间/重试计数/消费端扩展列都是信封或本地列,压根不进快照。</li>
* </ol>
*/
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<String> names = new ArrayList<>();
Iterator<Map.Entry<String, JsonNode>> 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();
}
}
@@ -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 扩展点)。
*
* <p><b>四件事按顺序各归各位,混一条就不可靠</b>:</p>
* <ol>
* <li><b>入队=答卷事务内的一次写</b>({@link #enqueueInTransaction}):outbox 行与答卷行同生同死,
* 进程在提交后、发送前被杀也只是"晚投",不是"丢投"——这是本类与旧
* {@code FcEventPublisher}(afterCommit 才落"发"这个动作、丢了由拉取兜底)的根本分工差别,
* 任务书明令不许照搬旧形态;</li>
* <li><b>afterCommit 只唤醒扫描</b>({@link #wakeScan}):扫的是库里的行,不是内存里的闭包;</li>
* <li><b>发送前再核授权</b>({@link #publish}):行的目标列只证明"入队那刻"合法,
* 撤权后不重放旧 grant(v2 §5),但已交出去的明文副本不承诺远程收回;</li>
* <li><b>broker 收下≠消费方应用</b>:confirm 只写 {@code broker_accepted_at},
* {@code consumer_applied_at} 只能由消费方签名回执写({@link #recordApplied})。</li>
* </ol>
*
* <p>没配 broker 时本类<b>不 no-op 丢事件</b>:行留在 {@code PENDING},启动与每次扫描各记一条日志,
* 状态可用 {@code failedPage} / {@code countByState} 观察(B.4"保留待投递并可观察")。</p>
*/
@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<String> 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<RabbitTemplate> 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<RabbitTemplate> 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 的并发控制)。
*
* <p>调用时机是<b>业务字段改完之后、{@code save} 之前</b>:JPQL 批量更新前 Hibernate 会先
* 自动 flush 已改字段(此时行上仍是旧号,CAS 匹配得上),随后这条 UPDATE 把号推到 next
* 并拿到行锁;并发对手在同一行上被挡到本事务提交,回来时 {@code WHERE} 不再匹配 → 0 行 → 拒。
* 顺序反过来(先改实体字段再 CAS)会造成自我拒绝。</p>
*/
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;
}
// ==================== 入队(答卷事务内) ====================
/**
* 在<b>调用方事务内</b>为该答卷的当前 revision 建逐订阅投递行。
*
* <p>无订阅的简单表单返回空列表——答卷照常提交,不创建任何消费投递记录(B.2 末句)。
* 撞 {@code uk_delivery_revision} 说明另一个事务已把同一 revision 投出去了(CAS 没挡住的极端形态),
* 整事务回滚是唯一安全处置,不做"改号重试"。</p>
*/
public int enqueueInTransaction(Answer answer, String eventType) {
if (answer == null || answer.getUuid() == null || answer.getAnswerRevision() == null) {
return 0;
}
List<SubscriptionGrant> 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<AnswerDelivery> 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:显式消费应用 + 租户域(<b>不是</b>资源 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);
});
}
/** 退避计数在<b>投递行</b>上,不在快照里:重试次数属于投递元数据,进哈希就会让同一 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 扩展点(只调用不编辑,签名即契约) ====================
/**
* 授权下的重投(对账修复入口)。状态码是<b>稳定值</b>,TASK_3 按码分支:
* <ul>
* <li>{@code NO_DELIVERY_ROW}:该消费方对这份答卷从来没有投递行(从未授权或从未订阅),
* 不新建——历史 revision 的明文快照只存在于已落库的不可变行里,无行即不可重建;</li>
* <li>{@code REVISION_MISMATCH}:期望 revision 不在已有行集内(同上,附当前最大号供运维判方向);</li>
* <li>{@code GRANT_REVOKED}:当前订阅授权不生效或纪元已进位,<b>绝不重放旧 grant</b>(v2 §5);</li>
* <li>{@code ALREADY_DELIVERED}:消费方已回执 APPLIED,重投只会被对端幂等吸收,不做无谓发送;</li>
* <li>{@code QUEUED}:行已回到 PENDING,等下一次扫描。</li>
* </ul>
*
* <p>{@code operationId} 是调用方的操作幂等键:同一 operationId 重复调用不再刷新退避,
* 直接回 {@code QUEUED}(同一次修复动作不因重试而变成多次重投)。</p>
*/
@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<AnswerDelivery> 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<AnswerDelivery> 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 批量回授权快照:<b>不回查任何答案值文档</b>,只回答"这个消费方在这所学校
* 对这张表当前还能不能收订阅"。判据完全复用 TASK_1 的 {@code requireSubscribe}——
* 拒绝(含跨校、无 grant、已撤权)统一落成 {@code subscribeAllowed=false},
* 消费端据此停止写入并把缓存里的旧纪元作废。
*
* <p>单次上限 100 张:这条接口是给消费端<b>逐批</b>核验用的(契约 v2 §5 撤权边界),
* 不是给每条事件回查用的——后者会把授权判定放大成热点。</p>
*/
public List<GrantSnapshotView> grantSnapshot(ConsumerPrincipal caller, Long consumerSchoolId,
List<String> formUuids) {
if (caller == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
}
if (consumerSchoolId == null || consumerSchoolId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)");
}
List<GrantSnapshotView> 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);
}
}
@@ -11,6 +11,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.model.MyModels; import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository; import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.AnswerRepository;
@@ -67,6 +68,8 @@ public class AnswerReadService {
private final FormFileRepository fileRepository; private final FormFileRepository fileRepository;
private final AnswerOpLogRepository opLogRepository; private final AnswerOpLogRepository opLogRepository;
private final FcEventPublisher events; 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, "只能删除自己提交的答卷"); throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷");
} }
answer.setIsDeleted(1); answer.setIsDeleted(1);
// 软删改变对外快照(墓碑也是变化):同事务取号 + 入队,B.1 明令不许漏这条
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
// 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填 // 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填
indexService.clear(answer.getId()); indexService.clear(answer.getId());
@@ -325,6 +330,7 @@ public class AnswerReadService {
opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)"); opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)");
// 事件:tombstoned(提交人侧软删与管理面各自实体 save,共用发布器这一个出口,§6.2) // 事件:tombstoned(提交人侧软删与管理面各自实体 save,共用发布器这一个出口,§6.2)
events.answerTombstoned(answer); events.answerTombstoned(answer);
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_TOMBSTONED);
log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId()); log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId());
} }
@@ -364,6 +370,7 @@ public class AnswerReadService {
dictCode -> dictService.allowedValues(answer.getSchId(), dictCode)); dictCode -> dictService.allowedValues(answer.getSchId(), dictCode));
assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson()); assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson());
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
// 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变 // 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变
indexService.rebuild(answer, pinned.versionRow().getSchemaJson()); indexService.rebuild(answer, pinned.versionRow().getSchemaJson());
@@ -372,6 +379,7 @@ public class AnswerReadService {
: "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim()); : "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim());
// 事件:updated(与管理面编辑同一点位;能走到这里说明该表未开冻结,V61 已在上面拦过) // 事件:updated(与管理面编辑同一点位;能走到这里说明该表未开冻结,V61 已在上面拦过)
events.answerUpdated(answer); events.answerUpdated(answer);
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(), log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(),
answer.getVersion()); answer.getVersion());
return toRow(answer, form); return toRow(answer, form);
@@ -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)。
*
* <p>出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入,
* 本应用身份取 {@code yh.app-auth},<b>密钥不进代码也不进日志</b>。
* 目标地址只有一个来源——{@code fc.answer.reconcile.targets} 里按<b>已验签 consumerAppKey</b>
* 登记的后台服务基址;请求体里、浏览器里出现的任何 URL 都不参与寻址(任务书改动点 3 末句)。</p>
*
* <p>失败语义:本类<b>不抛异常</b>。派发失败对上层只是一个稳定错误码,
* 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。</p>
*/
@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.<String, String>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;
}
}
@@ -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)。
*
* <p>拟配置时点=Asia/Shanghai 每日 02:00,<b>默认停用</b>:开关在
* {@code fc.answer.reconcile.schedule-enabled},没开时这个方法一行任务都不建
* (与 {@code FlowReconcileJob} 的"开关 + 能力闸"同形,但这里没有它的业务筛选——
* 答卷对账扫的是订阅与清单,不是流程状态 10)。</p>
*
* <p>闹钟本身不判授权:{@code createScheduledTasks} 只按 TASK_1 的生效订阅建任务,
* 未授权订阅一行都不产生。调度与派发是两个开关——建了任务不代表会发出去。</p>
*/
@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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,9 @@ import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.FillQuota; import com.yunhai.formcenter.entity.FillQuota;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.AnonModels; import com.yunhai.formcenter.model.AnonModels;
import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.AnswerRepository;
@@ -93,6 +95,10 @@ public class AnswerService {
private final AnswerIndexService indexService; private final AnswerIndexService indexService;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final FcEventPublisher events; 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) { public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); 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 位随机,实际不可能) // 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (replay != null && identity.ownsReplay(replay)) { if (replay != null && identity.ownsReplay(replay)) {
if (identity.hosted()) {
requireHostedReplay(identity, req, replay);
}
log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid());
return toView(replay, true); return toView(replay, true);
} }
@@ -176,10 +186,36 @@ public class AnswerService {
String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
? DEFAULT_APP_CODE : req.getAppCode().trim(); ? DEFAULT_APP_CODE : req.getAppCode().trim();
// 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。 // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。
// 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56) // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56)。
FormDefService.Published published = identity.anonymous() // hosted 是第三条取址分支,且它是"更严"的那条:坐标只认令牌里钉死的 uuid+version,
? defService.resolvePublishedForAnon(appCode, req.getFormCode()) // 既不走 code 遮蔽,也绝不回落到当前发布版(TASK_2 A.3)
: defService.resolvePublished(sessionSchId, appCode, req.getFormCode()); 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(); Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson(); String schemaJson = published.versionRow().getSchemaJson();
Long schId = identity.anonymous() ? form.getSchId() : sessionSchId; Long schId = identity.anonymous() ? form.getSchId() : sessionSchId;
@@ -204,6 +240,9 @@ public class AnswerService {
if (!identity.ownsReplay(existing)) { if (!identity.ownsReplay(existing)) {
throw replayConflict(identity); throw replayConflict(identity);
} }
if (identity.hosted()) {
requireHostedReplay(identity, req, existing);
}
return toView(existing, true); return toView(existing, true);
} }
@@ -255,6 +294,8 @@ public class AnswerService {
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
answer.setAnswerNo(nextAnswerNo(form)); answer.setAnswerNo(nextAnswerNo(form));
answer.setIdempotencyKey(idempotencyKey); answer.setIdempotencyKey(idempotencyKey);
// 首版快照号=1(之后每次对外可见的变化由 AnswerDeliveryService.nextRevision 行级 CAS 推进)
answer.setAnswerRevision(1L);
if (identity.anonymous()) { if (identity.anonymous()) {
// 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据) // 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据)
answer.setAnonIp(identity.anon().getAnonIp()); answer.setAnonIp(identity.anon().getAnonIp());
@@ -267,13 +308,24 @@ public class AnswerService {
// 9) 索引:只写 searchable 字段(V3/V9) // 9) 索引:只写 searchable 字段(V3/V9)
indexService.write(answer, schemaJson); indexService.write(answer, schemaJson);
// 9.5) 可信关联:意图 → 答卷。必须与答卷<b>同一事务</b>(A.3"原子关联"),
// 并且排在投递入队<b>之前</b>——outbox 载荷里的 intent 块就是从这里读的。
if (hostedCtx != null) {
contexts.link(hostedCtx, answer);
}
// 9.6) v2 快照投递入队(<b>同事务</b>,B.1/B.4):无订阅的简单表单不建任何投递行,
// 答卷照常提交(B.2 末句);撞同 revision 的唯一键则整事务回滚,不产生两份 hash
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_CREATED);
// 10) 事件:created 只在"真提交"这条路上发(幂等命中在第 4 关就 return 了,不发事件)。 // 10) 事件:created 只在"真提交"这条路上发(幂等命中在第 4 关就 return 了,不发事件)。
// 发布器内部是 afterCommit + 失败仅 warn + 未配 broker 即 no-op,主流程零风险(§6.2 裁决 2/3) // 发布器内部是 afterCommit + 失败仅 warn + 未配 broker 即 no-op,主流程零风险(§6.2 裁决 2/3)
events.answerCreated(answer); events.answerCreated(answer);
log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", log.info("[提交]{}{} answer={} form={}/{} v{} rev={} submitter={} subject={} quotaKey={}",
identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(), identity.anonymous() ? "[匿名]" : "", identity.hosted() ? "[hosted]" : "", answer.getUuid(),
answer.getVersion(), submitterId, storedSubjectId, quotaKey); answer.getAppCode(), answer.getCode(), answer.getVersion(), answer.getAnswerRevision(),
submitterId, storedSubjectId, quotaKey);
return toView(answer, false); return toView(answer, false);
} }
@@ -438,6 +490,29 @@ public class AnswerService {
return UUID.randomUUID().toString().replace("-", "").toLowerCase(); 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) { private AnswerModels.SubmitView toView(Answer answer, boolean replayed) {
AnswerModels.SubmitView view = new AnswerModels.SubmitView(); AnswerModels.SubmitView view = new AnswerModels.SubmitView();
view.setAnswerId(answer.getUuid()); view.setAnswerId(answer.getUuid());
@@ -463,19 +538,23 @@ public class AnswerService {
private final BoaUser user; private final BoaUser user;
private final AnonModels.Context anon; private final AnonModels.Context anon;
private final boolean anonymous; 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.user = user;
this.anon = anon; this.anon = anon;
this.anonymous = anonymous; this.anonymous = anonymous;
this.contextToken = contextToken == null || contextToken.trim().isEmpty() ? null : contextToken.trim();
} }
static SubmitIdentity ofSession(BoaUser user) { static SubmitIdentity ofSession(BoaUser user, String contextToken) {
return new SubmitIdentity(user, null, false); return new SubmitIdentity(user, null, false, contextToken);
} }
static SubmitIdentity ofAnon(AnonModels.Context anon) { static SubmitIdentity ofAnon(AnonModels.Context anon) {
return new SubmitIdentity(null, anon, true); // 匿名分支永远不带令牌:hosted 首批只允许登录本人,匿名完成消息不能用于本人办件(A.3)
return new SubmitIdentity(null, anon, true, null);
} }
boolean anonymous() { boolean anonymous() {
@@ -486,6 +565,14 @@ public class AnswerService {
return anon; return anon;
} }
String contextToken() {
return contextToken;
}
boolean hosted() {
return contextToken != null;
}
/** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */ /** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */
Integer submitterId() { Integer submitterId() {
return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId(); return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId();
@@ -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 扩展点)。
*
* <p><b>成员闭集就是契约</b>:{@code schId/appCode/code/submitterId/subjectId/status/values/
* answerNo/createTime/updateTime},再加一个<b>仅对该 consumer 可见</b>的 {@code intent} 关联块。
* 匿名 IP/UA 是审计数据、库内 {@code id} 是内部坐标,两者都<b>不进</b>快照(v2 契约 §2 明文);
* 附件保持 {@code {id,fileName,key}} 元数据,永远不塞文件字节(P34 的口径)。</p>
*
* <p><b>消费端不回查值文档</b>(B.6):快照里给的就是全文,投出去即完整。因此本类的输出与
* {@link AnswerCanonicalizer} 的哈希口径必须是同一份字节——两处都从这里取,不给分叉机会。</p>
*
* <p>{@code intent} 块剔除出哈希(fc.canon.v1 规则⑥):同一 revision 对 A 应用带意图、
* 对 B 应用不带,若不剔除就会出现"同版两份 hash",直接违 B.1 的不变式。</p>
*/
@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 扩展点:授权下的当前快照读取(对账/补投用,签名消费方直取)。
*
* <p>判据只有 TASK_1 那把 {@code requireRead}(读快照=读答卷数据),租户域按
* {@code consumerSchoolId} 限定行集——平台级表单也给不出别校的答卷。
* 返回的 revision/hash 与投递行同一条规范化口径,对账两侧可比对。</p>
*/
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();
}
}
@@ -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"换成一个不可自报的主体对象。
*
* <p><b>身份只有一个来源</b>:调用方必须带 {@code X-App-Key/X-Timestamp/X-Signature} 三件套,
* 由平台 {@code AppAuthFilter} 验签通过后才把 appKey 写进请求属性 {@code yh.app.auth}。
* 本类只转读那个属性,因此"没签名"与"签名不是我们的"在同一处就被关死——
* 请求里另有一个自由输入的 {@code appCode},它只是资源筛选项,<b>永远不构成身份</b>(v3 §1)。</p>
*
* <p>签名密钥可来自trusted-apps,但它不证明UserBase登记事实。
* 因此另经OpenApiAppClient查询UserBase注册信息;不可达或查不到均拒绝。
* 这里只取存在性,密钥值不出本类。</p>
*/
@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;
}
}
}
@@ -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)。
*
* <p>三段判定顺序照 v3 §1:<b>①调用方身份</b>({@link ConsumerPrincipalResolver},只认验签后的
* appKey)→ <b>②资源与归属</b>(按 uuid 定位表单头表与其 owner 行)→ <b>③owner 或显式 grant</b>。
* 三段里任何一段给不出肯定答案都是同一句拒绝:跨校、缺归属、不存在、未授权在对外文案上不可区分
* (uuid 是半公开坐标,"存在但你不配"这句话本身就是探测面,同契约 §1 的既有口径);
* 真实原因只进服务端 warn 日志。</p>
*
* <p><b>读与订阅分权</b>:owner 天然能读自己那张表的 schema/答卷(资源拥有者),但订阅
* 必须另开一行 grant——"能看"不等于"能往外推"。这也是 TASK_2/TASK_3 唯一的授权入口,
* 它们不允许再对着仓库拼第二套规则(v3 §7)。</p>
*
* <p><b>租户维度</b>:{@code consumerSchoolId} 既是授权的生效域,也是读答卷时的行集限定域。
* 平台级表单({@code form.sch_id=0})对某校授予后,那一校看到的答卷仍只有自己的行
* (行集由调用侧的 {@code schId} 条件保证,本服务不放宽它)。</p>
*/
@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):<b>owner 不算</b>,必须有生效且 {@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)。
*
* <p>不带调用方:事件投递发生在提交链路里,那一刻没有"调用方应用",判据只能是
* "这张表 + 这份答卷所在校"上生效的订阅 grant。撤权后这里就查不到,后续投递自然断,
* 但已生成的任务不被改写(v3 §7)。</p>
*/
public List<SubscriptionGrant> listEnabledSubscriptions(String formUuid, Long answerSchoolId) {
List<SubscriptionGrant> 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<String> filterReadable(ConsumerPrincipal caller, Long consumerSchoolId, Collection<Form> forms) {
Set<String> readable = new HashSet<>();
if (caller == null || consumerSchoolId == null || forms == null || forms.isEmpty()) {
return readable;
}
Map<String, Form> byUuid = new LinkedHashMap<>();
for (Form form : forms) {
if (form != null && form.getUuid() != null) {
byUuid.put(form.getUuid(), form);
}
}
if (byUuid.isEmpty()) {
return readable;
}
Map<String, String> ownerOf = new HashMap<>();
for (FormOwnership ownership : ownershipRepository.findByFormUuidIn(byUuid.keySet())) {
ownerOf.put(ownership.getFormUuid(), ownership.getOwnerAppKey());
}
Set<String> granted = new HashSet<>();
for (FormConsumerGrant grant : grantRepository.findForConsumer(byUuid.keySet(), consumerSchoolId,
caller.getAppKey())) {
if (isOne(grant.getReadAllowed())) {
granted.add(grant.getFormUuid());
}
}
for (Form form : byUuid.values()) {
String owner = ownerOf.get(form.getUuid());
boolean isOwner = caller.getAppKey().equals(owner) && isOwnDomain(form, consumerSchoolId);
if (isOwnDomain(form, consumerSchoolId) && (isOwner || granted.contains(form.getUuid()))) {
readable.add(form.getUuid());
}
}
return readable;
}
private boolean canRead(Form form, Long consumerSchoolId, String appKey) {
if (appKey == null || consumerSchoolId == null || consumerSchoolId <= 0
|| !isOwnDomain(form, consumerSchoolId)) {
return false;
}
FormOwnership ownership = ownershipRepository.findByFormUuid(form.getUuid()).orElse(null);
if (ownership != null && appKey.equals(ownership.getOwnerAppKey()) && isOwnDomain(form, consumerSchoolId)) {
return true;
}
return grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(
form.getUuid(), consumerSchoolId, appKey)
.filter(g -> isActive(g) && isOne(g.getReadAllowed()))
.isPresent();
}
/** 资源归属校与消费租户是两个维度:本校资源 or 平台级资源,别校的按不可读处理 */
private static boolean isOwnDomain(Form form, Long consumerSchoolId) {
Long formSch = form.getSchId();
return formSch != null && (formSch.equals(consumerSchoolId) || formSch == Form.PLATFORM_SCH_ID);
}
private static boolean isActive(FormConsumerGrant grant) {
return isOne(grant.getEnabled());
}
private static boolean isOne(Integer flag) {
return Integer.valueOf(1).equals(flag);
}
/** 统一拒绝:对外不给"存在/不存在/不是你的"的差别,真实原因进服务端日志 */
private void deny(ConsumerPrincipal caller, Long consumerSchoolId, Form form, String capability) {
log.warn("[consumer ACL] 拒绝 capability={} appKey={} consumerSchoolId={} formUuid={} formSchId={} "
+ "(对外按表单不存在回答,不给存在性差别)",
capability, caller == null ? null : caller.getAppKey(), consumerSchoolId,
form.getUuid(), form.getSchId());
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid());
}
private Form requireForm(String formUuid) {
if (formUuid == null || formUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空");
}
Form form = formRepository.findByUuid(formUuid.trim()).orElse(null);
if (form == null) {
// 与 readDomainForm 同一句文案:不存在与"不属于你"不可区分
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid);
}
return form;
}
// ==================== 管理面:owner 绑定 ====================
/** 读 owner 绑定(未绑定返回 null,管理面据此提示"还没有资源拥有者") */
public OwnershipView findOwnership(Long domain, String formUuid) {
Form form = requireDomainForm(domain, formUuid);
FormOwnership ownership = ownershipRepository.findByFormUuid(form.getUuid()).orElse(null);
return ownership == null ? null : toOwnershipView(ownership);
}
/**
* 设/换 owner 绑定(v3 §2):只有管理面写得了,签名应用没有自报通道。
*
* @param superAdmin 会话是否超管——平台级表单({@code sch_id=0})的绑定只允许超管,
* 否则校管理员可以把自己应用的 key 绑到平台资源上,等于从管理面把资源认领走
*/
@Transactional
public OwnershipView setOwner(Long domain, String formUuid, String ownerAppKey, boolean superAdmin) {
Form form = requireDomainForm(domain, formUuid);
if (form.getSchId() != null && form.getSchId() == Form.PLATFORM_SCH_ID && !superAdmin) {
throw new BizException(ErrorCode.FORBIDDEN, "平台级表单的资源拥有者绑定仅超管可设置");
}
String clean = ownerAppKey == null ? null : ownerAppKey.trim();
if (clean == null || clean.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "ownerAppKey 必填(UserBase 注册分配的 appKey)");
}
FormOwnership ownership = ownershipRepository.findByFormUuid(form.getUuid()).orElseGet(() -> {
FormOwnership fresh = new FormOwnership();
fresh.setFormUuid(form.getUuid());
return fresh;
});
ownership.setFormSchoolId(form.getSchId());
ownership.setOwnerAppKey(clean);
FormOwnership saved = ownershipRepository.save(ownership);
log.info("[consumer ACL] owner 绑定 formUuid={} formSchoolId={} ownerAppKey={}",
saved.getFormUuid(), saved.getFormSchoolId(), saved.getOwnerAppKey());
return toOwnershipView(saved);
}
// ==================== 管理面:grant ====================
/** grant 清单(含已撤销行) */
public List<GrantView> listGrants(Long domain, String formUuid) {
Form form = requireDomainForm(domain, formUuid);
List<GrantView> out = new ArrayList<>();
for (FormConsumerGrant grant : grantRepository.findLiveByForm(form.getUuid())) {
out.add(toGrantView(grant));
}
return out;
}
/**
* 授权/重申(三元键 upsert)。
*
* <p>校级管理员只能给本校授({@code consumerSchoolId} 必须等于会话域),超管可代某校授;
* 消费租户给 0 一律拒——{@code sch_id=0} 是平台级资源域,不是一个能答卷的租户。</p>
*/
@Transactional
public GrantView grant(Long domain, GrantRequest req, boolean superAdmin) {
if (req == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "请求体不得为空");
}
Form form = requireDomainForm(domain, req.getFormUuid());
Long consumerSchoolId = req.getConsumerSchoolId();
if (consumerSchoolId == null || consumerSchoolId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必填且为真实学校(≥1)");
}
if (!isOwnDomain(form, consumerSchoolId)) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + req.getFormUuid());
}
if (!superAdmin && !consumerSchoolId.equals(domain)) {
throw new BizException(ErrorCode.FORBIDDEN, "只能给本校授予消费授权(当前管理域 " + domain + ")");
}
String consumerAppKey = req.getConsumerAppKey() == null ? null : req.getConsumerAppKey().trim();
if (consumerAppKey == null || consumerAppKey.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "consumerAppKey 必填(UserBase 注册分配的 appKey)");
}
FormConsumerGrant grant = grantRepository
.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(form.getUuid(), consumerSchoolId, consumerAppKey)
.orElse(null);
boolean read = Boolean.TRUE.equals(req.getReadAllowed());
boolean subscribe = Boolean.TRUE.equals(req.getSubscribeAllowed());
if (!read && !subscribe) {
throw new BizException(ErrorCode.PARAM_ERROR, "readAllowed / subscribeAllowed 至少开一把(默认拒绝)");
}
if (grant == null) {
grant = new FormConsumerGrant();
grant.setFormUuid(form.getUuid());
grant.setConsumerSchoolId(consumerSchoolId);
grant.setConsumerAppKey(consumerAppKey);
grant.setGrantVersion(1);
} else if (!isOne(grant.getEnabled())
|| isOne(grant.getReadAllowed()) != read
|| isOne(grant.getSubscribeAllowed()) != subscribe) {
// 撤销后重申、以及"还开着但能力变了"(如只关订阅)都是新纪元:
// 订阅投递带的是 grantVersion,能力收窄却不进位会让已发出去的事件继续看起来合法
grant.setGrantVersion(grant.getGrantVersion() + 1);
}
grant.setReadAllowed(read ? 1 : 0);
grant.setSubscribeAllowed(subscribe ? 1 : 0);
grant.setEnabled(1);
FormConsumerGrant saved = grantRepository.save(grant);
log.info("[consumer ACL] 授权 formUuid={} consumerSchoolId={} consumerAppKey={} read={} subscribe={} "
+ "grantVersion={}", saved.getFormUuid(), saved.getConsumerSchoolId(),
saved.getConsumerAppKey(), saved.getReadAllowed(), saved.getSubscribeAllowed(),
saved.getGrantVersion());
return toGrantView(saved);
}
/**
* 撤销({@code enabled=0} + {@code grantVersion} 进位):不删行,留着审计与幂等判据。
* 撤权使后续目录/读取/订阅检查失败,已经生成的任务不被改写(v3 §7)。
*/
@Transactional
public GrantView revoke(Long domain, RevokeRequest req, boolean superAdmin) {
if (req == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "请求体不得为空");
}
Form form = requireDomainForm(domain, req.getFormUuid());
Long consumerSchoolId = req.getConsumerSchoolId();
if (consumerSchoolId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必填");
}
if (!superAdmin && !consumerSchoolId.equals(domain)) {
throw new BizException(ErrorCode.FORBIDDEN, "只能撤销本校的消费授权(当前管理域 " + domain + ")");
}
String consumerAppKey = req.getConsumerAppKey() == null ? null : req.getConsumerAppKey().trim();
FormConsumerGrant grant = consumerAppKey == null ? null
: grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(
form.getUuid(), consumerSchoolId, consumerAppKey).orElse(null);
if (grant == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "授权不存在,无需撤销");
}
if (isOne(grant.getEnabled())) {
grant.setEnabled(0);
grant.setGrantVersion(grant.getGrantVersion() + 1);
grantRepository.save(grant);
}
log.info("[consumer ACL] 撤权 formUuid={} consumerSchoolId={} consumerAppKey={} grantVersion={}",
grant.getFormUuid(), grant.getConsumerSchoolId(), grant.getConsumerAppKey(), grant.getGrantVersion());
return toGrantView(grant);
}
/** 管理面的域复核:他校表单与不存在的表单同一口径(同 {@code FormDefService#readDomainForm}) */
private Form requireDomainForm(Long domain, String formUuid) {
Form form = requireForm(formUuid);
if (domain == null || (form.getSchId() == null
|| !(form.getSchId().equals(domain) || form.getSchId() == Form.PLATFORM_SCH_ID))) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid);
}
return form;
}
// ==================== 出参组装 ====================
private static SubscriptionGrant toSubscriptionGrant(FormConsumerGrant grant) {
SubscriptionGrant view = new SubscriptionGrant();
view.setFormUuid(grant.getFormUuid());
view.setConsumerSchoolId(grant.getConsumerSchoolId());
view.setConsumerAppKey(grant.getConsumerAppKey());
view.setReadAllowed(isOne(grant.getReadAllowed()));
view.setSubscribeAllowed(isOne(grant.getSubscribeAllowed()));
view.setGrantVersion(grant.getGrantVersion() == null ? 0 : grant.getGrantVersion());
return view;
}
private static GrantView toGrantView(FormConsumerGrant grant) {
GrantView view = new GrantView();
view.setFormUuid(grant.getFormUuid());
view.setConsumerSchoolId(grant.getConsumerSchoolId());
view.setConsumerAppKey(grant.getConsumerAppKey());
view.setReadAllowed(isOne(grant.getReadAllowed()));
view.setSubscribeAllowed(isOne(grant.getSubscribeAllowed()));
view.setEnabled(isOne(grant.getEnabled()));
view.setGrantVersion(grant.getGrantVersion() == null ? 0 : grant.getGrantVersion());
return view;
}
private static OwnershipView toOwnershipView(FormOwnership ownership) {
OwnershipView view = new OwnershipView();
view.setFormUuid(ownership.getFormUuid());
view.setFormSchoolId(ownership.getFormSchoolId());
view.setOwnerAppKey(ownership.getOwnerAppKey());
return view;
}
/** 目录行组装(版本号取当前发布指针;目录只出已发布行,调用侧已过滤) */
public static CatalogItem toCatalogItem(Form form) {
CatalogItem item = new CatalogItem();
item.setFormUuid(form.getUuid());
item.setCode(form.getCode());
item.setName(form.getName());
item.setAppCode(form.getAppCode());
item.setVersion(form.getCurrentVersion());
item.setAudience(com.yunhai.formcenter.enums.FormEnums.Audience.namesOf(form.getAudience()));
return item;
}
}
@@ -6,8 +6,12 @@ import com.yunhai.formcenter.common.FormValueSchemaBuilder;
import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.FormAccessModels.CatalogItem;
import com.yunhai.formcenter.model.FormAccessModels.CatalogPage;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.model.OapiModels; import com.yunhai.formcenter.model.OapiModels;
import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -19,7 +23,9 @@ import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* 一期对外读接口服务(契约 §9 两端点)。 * 一期对外读接口服务(契约 §9 两端点)。
@@ -31,8 +37,14 @@ import java.util.List;
* *
* <p><b>与 CAS 读路径的分工</b>:契约 §8 的行级数据范围矩阵挂在<b>会话身份</b>上,oapi 面没有会话用户 * <p><b>与 CAS 读路径的分工</b>:契约 §8 的行级数据范围矩阵挂在<b>会话身份</b>上,oapi 面没有会话用户
* (签名方是应用),因此不走 {@link AnswerScopeQueryBuilder}——它给的是"这张表的结构"与"这一条答卷", * (签名方是应用),因此不走 {@link AnswerScopeQueryBuilder}——它给的是"这张表的结构"与"这一条答卷",
* 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死(一期对外只读单条与 schema, * 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死。这一条写清楚,避免后来人
* 不提供全量拉取)。这一条写清楚,避免后来人"顺手"加一个 /oapi/form/list。</p> * "顺手"加一个不带授权的全量列表。</p>
*
* <p><b>v3 起的授权变化</b>(TASK_1,见 {@code docs/consumer-access-v3.md}):原来"应用签名通过=三个
* 读端点全开",现在签名只确立<b>身份</b>,能不能读某张表另过 {@link FormConsumerAccessService#requireRead}
* (owner 或显式 read grant,默认拒绝)。带 {@code caller} 的方法才是端点入口,原不带 caller 的方法是
* 应用内部的既有解析路径。同批新增的 {@code GET /oapi/form/forms} 是<b>选择器目录</b>而不是全量拉取:
* 已发布 ∩ 未软删 ∩ 调用方可读,再分页(上限 100)。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -45,6 +57,8 @@ public class OapiFormService {
private final FormDefService defService; private final FormDefService defService;
private final FormDictService dictService; private final FormDictService dictService;
private final AnswerRepository answerRepository; private final AnswerRepository answerRepository;
private final FormRepository formRepository;
private final FormConsumerAccessService accessService;
/** /**
* {@code /oapi/form/schema} 的<b>入口分流</b>(T11):{@code uuid} 非空走按 uuid 取版本行的新路径, * {@code /oapi/form/schema} 的<b>入口分流</b>(T11):{@code uuid} 非空走按 uuid 取版本行的新路径,
@@ -95,6 +109,141 @@ public class OapiFormService {
return view; return view;
} }
// ==================== v3 消费应用授权入口(TASK_1:/oapi 面唯一的对外调用形态) ====================
//
// 下面这几个带 caller 的方法是 v3 §3 授权的落点,上面不带 caller 的原方法是应用内部的
// 既有解析路径(管理链路、快照重放与本仓 IT 的载荷断言都在用它)。两条不是"新旧两套口径":
// HTTP 面上 /oapi/** 只能从带 caller 的形态进来,原方法没有任何端点可达。
/** 目录页大小缺省与上限(v3 §3:上限 100,超限直接拒而不是静默收窄) */
private static final int CATALOG_DEFAULT_PAGE_SIZE = 20;
private static final int CATALOG_MAX_PAGE_SIZE = 100;
/**
* {@code GET /oapi/form/schema} 的授权形态(v3 §3):<b>read 判定先于返回任何载荷</b>。
*
* <p>uuid 形态手里就有坐标,先判读权再解析——被拒时与"这张表不存在"同一句回答,
* 不给探测面。code 形态只能先解析(消费方给的是人读 code,不是 uuid),解析出来的
* {@code formUuid} 同样要过这一把;解析自身那句"未发布/停用"是改造前就有的既有行为,
* 本任务不改变它。</p>
*/
public OapiModels.SchemaView schema(ConsumerPrincipal caller, Long schId, String appCode, String code,
String uuid, Integer version) {
if (notBlank(uuid)) {
accessService.requireRead(caller, schId, uuid.trim());
return schema(schId, appCode, code, uuid, version);
}
OapiModels.SchemaView view = schema(schId, appCode, code, uuid, version);
accessService.requireRead(caller, schId, view.getFormUuid());
return view;
}
/**
* {@code GET /oapi/form/answer} 的授权形态。
*
* <p>读权被拒时对外只能说"这条答卷不存在"——答"表单不存在"等于同时确认了答卷和那张表都在,
* 那正是 v3 §2 要关死的差别信息。</p>
*/
public OapiModels.AnswerView answer(ConsumerPrincipal caller, Long schId, String answerId) {
OapiModels.AnswerView view = answer(schId, answerId);
try {
accessService.requireRead(caller, schId, view.getFormUuid());
} catch (BizException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在:" + answerId);
}
return view;
}
/** {@code GET /oapi/form/answers} 的授权形态:先判读权,再按原有游标口径给行集 */
public OapiModels.AnswersView answers(ConsumerPrincipal caller, Long schId, String formUuid, String after,
String updatedSince, Integer limit) {
accessService.requireRead(caller, schId, formUuid);
return answers(schId, formUuid, after, updatedSince, limit);
}
/**
* 表单目录(v3 §3,{@code GET /oapi/form/forms}):<b>先过滤再分页</b>,不是"分页后再筛"。
*
* <p>取域是"会话声明校 ∪ 平台级(0)、已发布、未软删"(复用 {@code findPublishedInDomain},
* 与填报入口同一把 SQL),再把可读集换成调用方的授权视图(owner ∪ 显式 read grant,批量两次查询
* 放大成常数级),最后按 {@code appCode}/{@code keyword} 收窄、定序、切页。
* {@code total} 是过滤后的条数——消费方的选择器要能据此判断"还有没有下一页"。</p>
*
* <p>不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是<b>两个都合法的可选项</b>
* (遮蔽规则是给按 code 的 resolve 入口用的)。{@code appCode} 只是筛选项,不参与授权判定。</p>
*/
public CatalogPage catalog(ConsumerPrincipal caller, Long schId, String appCode, String keyword,
Integer page, Integer pageSize) {
requireSchId(schId);
int pageNo = page == null ? 1 : page;
int size = pageSize == null ? CATALOG_DEFAULT_PAGE_SIZE : pageSize;
if (pageNo < 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "page 从 1 起算");
}
if (size < 1 || size > CATALOG_MAX_PAGE_SIZE) {
throw new BizException(ErrorCode.PARAM_ERROR,
"pageSize 需在 1~" + CATALOG_MAX_PAGE_SIZE + " 之间(默认 " + CATALOG_DEFAULT_PAGE_SIZE + ")");
}
String cleanApp = appCode == null ? null : appCode.trim();
String cleanKeyword = keyword == null ? null : keyword.trim().toLowerCase();
List<Form> candidates = new ArrayList<>();
for (Form form : formRepository.findPublishedInDomain(Arrays.asList(schId, Form.PLATFORM_SCH_ID))) {
if (cleanApp != null && !cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
continue;
}
if (cleanKeyword != null && !cleanKeyword.isEmpty() && !matchesKeyword(form, cleanKeyword)) {
continue;
}
candidates.add(form);
}
Set<String> readable = accessService.filterReadable(caller, schId, candidates);
List<Form> filtered = new ArrayList<>();
for (Form form : candidates) {
if (readable.contains(form.getUuid())) {
filtered.add(form);
}
}
filtered.sort((a, b) -> {
LocalDateTime ta = a.getUpdateTime(), tb = b.getUpdateTime();
if (ta != null && tb != null && !ta.equals(tb)) {
return tb.compareTo(ta);
}
if (ta == null && tb != null) {
return 1;
}
if (ta != null && tb == null) {
return -1;
}
String ca = a.getCode() == null ? "" : a.getCode();
String cb = b.getCode() == null ? "" : b.getCode();
int byCode = ca.compareTo(cb);
return byCode != 0 ? byCode : Long.compare(nullToZero(b.getId()), nullToZero(a.getId()));
});
int from = (pageNo - 1) * size;
List<CatalogItem> items = new ArrayList<>();
if (from < filtered.size()) {
for (Form form : filtered.subList(from, Math.min(from + size, filtered.size()))) {
items.add(FormConsumerAccessService.toCatalogItem(form));
}
}
CatalogPage view = new CatalogPage();
view.setItems(items);
view.setTotal(filtered.size());
return view;
}
private static boolean matchesKeyword(Form form, String lowerKeyword) {
return (form.getName() != null && form.getName().toLowerCase().contains(lowerKeyword))
|| (form.getCode() != null && form.getCode().toLowerCase().contains(lowerKeyword));
}
private static long nullToZero(Long value) {
return value == null ? 0L : value;
}
/** /**
* 单条答卷(契约 §9):钉住版本的内联 schema + 值文档。 * 单条答卷(契约 §9):钉住版本的内联 schema + 值文档。
* 软删答卷对外不可见(与四条 CAS 读路径同一口径,V30)。 * 软删答卷对外不可见(与四条 CAS 读路径同一口径,V30)。
@@ -0,0 +1,368 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.enums.FormEnums;
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.repository.AnswerRepository;
import com.yunhai.formcenter.repository.SubmissionContextRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 可信提交上下文服务(answer-delivery-v2 §6,TASK_2 A.2/A.3/A.4)。
*
* <p><b>它把"这次填报属于那次业务意图"变成中心侧事实</b>:意图由消费方服务端签名申请、
* 令牌由中心签发并只存摘要、答卷在同一事务里原子关联回意图。浏览器因此永远不需要、
* 也没办法伪造 {@code bizId/submitterId/schId}——那些坐标在签发时就钉进了
* {@link SubmissionContext},提交时按令牌反查。</p>
*
* <p>三条 fail-closed 规则:</p>
* <ol>
* <li><b>授权只过 TASK_1 那把锁</b>({@code requireRead} + {@code requireSubscribe}),
* 本类不拼第二套 ACL;跨校/未授权与"表单不存在"同一口径;</li>
* <li><b>版本必须成对且精确</b>:按 {@code formUuid+formVersion} 取版本行,
* 拿不到就拒——hosted 链路上"回落到当前发布版"等于把办件挂在另一张表上;</li>
* <li><b>可嵌入 origin 与回调 URL 只从服务端配置取</b>(按验签 appKey 查
* {@code form-center.hosted-fill.*}),请求体里出现这两个字段也不采信。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SubmissionContextService {
private static final DateTimeFormatter TS = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final int INTENT_MAX = 64;
private static final SecureRandom RANDOM = new SecureRandom();
private final SubmissionContextRepository contextRepository;
private final AnswerRepository answerRepository;
private final FormConsumerAccessService accessService;
private final FormDefService defService;
private final AnswerSnapshotService snapshots;
private final Environment environment;
/**
* 签发(消费方服务端签名调用)。
*
* <p><b>同意图重试回到同一行</b>:{@code uk_ctx_intent} 结构上不允许第二个业务意图。
* 令牌在<b>同一行上轮换</b>(明文只在响应里出现一次,库里只有摘要,无法原样回吐旧令牌),
* 旧令牌随即失效——取舍是"同一意图最多只有一个可提交凭据",比"旧页面还能再交一次"安全。
* 已关联答卷的意图不再签发:那种重试应当去读收据,而不是再拿一次填报入口。</p>
*/
@Transactional
public ContextIssueView issue(ConsumerPrincipal caller, ContextIssueRequest req) {
if (caller == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
}
String intentId = requireLength(req == null ? null : req.getIntentId(), INTENT_MAX, "intentId");
Long consumerSchoolId = req.getConsumerSchoolId();
if (consumerSchoolId == null || consumerSchoolId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)");
}
String formUuid = requireLength(req.getFormUuid(), 32, "formUuid");
Integer formVersion = req.getFormVersion();
if (formVersion == null || formVersion <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR,
"formVersion 必填且必须是确切版本号(hosted 链路不接受按当前发布版回落)");
}
Integer submitterId = req.getSubmitterId();
if (submitterId == null || submitterId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "submitterId 必填(消费方服务端断言的填报人)");
}
// ① 授权:read(读得到这张表)+ subscribe(有权接收它的答卷),两把都过才给上下文
accessService.requireRead(caller, consumerSchoolId, formUuid);
accessService.requireSubscribe(caller, consumerSchoolId, formUuid);
// ② 坐标:成对精确取版本行,且这张表当前确实开放填报
FormDefService.Published pinned = defService.resolvePublishedByUuid(consumerSchoolId, formUuid, formVersion);
Form form = pinned.form();
if (Integer.valueOf(1).equals(form.getIsDeleted())
|| form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode()
|| !FormRuntimeRules.isFillOpen(form)) {
throw new BizException(ErrorCode.CUSTOM, "表单当前不开放填报,不能签发提交上下文:" + form.getCode());
}
String embedOrigin = requireConfig("form-center.hosted-fill.embed-origin." + caller.getAppKey(),
"可嵌入 origin 未登记");
String callbackUrl = requireConfig("form-center.hosted-fill.callback-url." + caller.getAppKey(),
"确认回调地址未登记");
SubmissionContext ctx = contextRepository.findByConsumerAppKeyAndIntentId(caller.getAppKey(), intentId)
.orElse(null);
boolean reused = ctx != null;
if (ctx != null && ctx.getAnswerUuid() != null) {
throw new BizException(ErrorCode.CUSTOM, "该业务意图已关联答卷,不能重复签发提交上下文;"
+ "请改读收据 GET /oapi/form/submission-context/{intentId}/receipt");
}
if (ctx == null) {
ctx = new SubmissionContext();
ctx.setConsumerAppKey(caller.getAppKey());
ctx.setIntentId(intentId);
ctx.setConsumerSchoolId(consumerSchoolId);
ctx.setFormUuid(formUuid);
ctx.setFormVersion(formVersion);
ctx.setAppCode(form.getAppCode());
ctx.setCode(form.getCode());
ctx.setSubmitterId(submitterId);
ctx.setEmbedOrigin(embedOrigin);
ctx.setCallbackUrl(callbackUrl);
} else {
// 换人/换校/换坐标的重试不是同一意图重试,必须拒——否则会静默改语义
requireSame(ctx, consumerSchoolId, formUuid, formVersion, submitterId);
}
String token = rotateToken(ctx);
try {
ctx = contextRepository.saveAndFlush(ctx);
} catch (DataIntegrityViolationException e) {
// 并发双签发:对方事务已落下同一意图行,在同一行上轮换一次令牌返回,
// 绝不新建第二条业务意图(这才是 uk_ctx_intent 存在的理由)
SubmissionContext existing = contextRepository
.findByConsumerAppKeyAndIntentId(caller.getAppKey(), intentId).orElse(null);
if (existing == null || existing.getAnswerUuid() != null) {
throw e;
}
log.info("[hosted] 并发签发命中同一意图行,同行为准 appKey={} intentId={}",
caller.getAppKey(), intentId);
return issueView(existing, rotateToken(contextRepository.saveAndFlush(existing)), true, form);
}
log.info("[hosted] 签发上下文 appKey={} intentId={} school={} form={}@v{} submitter={} reused={}",
caller.getAppKey(), intentId, consumerSchoolId, formUuid, formVersion, submitterId, reused);
return issueView(ctx, token, reused, form);
}
/** 换一枚不可猜测令牌(160 位随机)并刷新有效期;明文只回给调用方一次 */
private String rotateToken(SubmissionContext ctx) {
byte[] random = new byte[20];
RANDOM.nextBytes(random);
String token = hex(random);
ctx.setTokenDigest(sha256Hex(token));
ctx.setExpiresAt(LocalDateTime.now().plusMinutes(ttlMinutes()));
return token;
}
/**
* 兑换令牌(答卷提交事务内调用,A.3)。
*
* <p>四道逐字复核:令牌存在 → 未过期 → 会话用户=断言填报人 → 会话校=上下文租户域。
* 任一不符一律 {@code FORBIDDEN},且不给"存在但不对"与"不存在"的差别(令牌本身就是探测面)。
* 匿名通道不会走到这里:{@code submitAnon} 没有会话用户,{@code submitterId} 对不上哨兵 0
* ——契约里"匿名完成消息不能用于本人办件"就是被这条判据结构性挡住的。</p>
*/
public SubmissionContext redeem(String rawToken, BoaUser user, Long sessionSchId) {
String token = rawToken == null ? null : rawToken.trim();
if (token == null || token.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "提交上下文无效或已过期");
}
SubmissionContext ctx = contextRepository.findByTokenDigest(sha256Hex(token)).orElse(null);
if (ctx == null) {
throw new BizException(ErrorCode.FORBIDDEN, "提交上下文无效或已过期");
}
if (ctx.getExpiresAt() != null && ctx.getExpiresAt().isBefore(LocalDateTime.now())) {
throw new BizException(ErrorCode.CUSTOM, "提交上下文已过期(" + TS.format(ctx.getExpiresAt()) + "),请回到办事页面重新发起");
}
if (user == null || user.getId() == null || !user.getId().equals(ctx.getSubmitterId())) {
throw new BizException(ErrorCode.FORBIDDEN, "提交上下文不属于当前登录用户(token 错人)");
}
if (sessionSchId == null || !sessionSchId.equals(ctx.getConsumerSchoolId())) {
throw new BizException(ErrorCode.FORBIDDEN, "提交上下文不属于当前学校(token 错校)");
}
return ctx;
}
/** 令牌绑定的坐标与本次请求是否同一张表(hosted 页不许被拿去提交到别的表单) */
public void assertCoordinates(SubmissionContext ctx, String appCode, String formCode) {
if (!equalsText(ctx.getAppCode(), appCode) || !equalsText(ctx.getCode(), formCode)) {
throw new BizException(ErrorCode.FORBIDDEN,
"提交上下文的表单坐标与本次提交不符(" + ctx.getAppCode() + "/" + ctx.getCode() + ")");
}
}
/**
* 原子关联(答卷提交事务内):意图 → 答卷。
* {@code uk_ctx_answer} 保证一份答卷只挂一个意图、一个意图只挂一份答卷。
*/
public void link(SubmissionContext ctx, Answer answer) {
if (ctx.getAnswerUuid() != null) {
if (ctx.getAnswerUuid().equals(answer.getUuid())) {
return; // 同 token 同幂等键的重放:原答卷,不重复关联
}
throw new BizException(ErrorCode.CUSTOM, "该提交上下文已关联其它答卷,不能重复使用");
}
ctx.setAnswerUuid(answer.getUuid());
ctx.setLinkedRevision(answer.getAnswerRevision());
ctx.setLinkedAt(LocalDateTime.now());
try {
contextRepository.saveAndFlush(ctx);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.CUSTOM, "提交上下文并发关联冲突,本次提交回滚,请重试");
}
log.info("[hosted] 原子关联 intent={} answer={} rev={} appKey={}", ctx.getIntentId(),
answer.getUuid(), answer.getAnswerRevision(), ctx.getConsumerAppKey());
}
/** 按令牌反查已关联的答卷(同 token 重放时返回原答卷用) */
public Answer linkedAnswer(SubmissionContext ctx) {
if (ctx.getAnswerUuid() == null) {
return null;
}
return answerRepository.findByUuidAndSchId(ctx.getAnswerUuid(), ctx.getConsumerSchoolId()).orElse(null);
}
/**
* 收据(消费方服务端签名读取,A.4):<b>只向所属消费应用返回</b>,
* 且读取时再核一次 read 授权——撤权后修复读取要停(v2 §5 边界),
* 但已在授权期内交出去的副本不承诺远程收回,所以本方法不改写任何已发生的事实。
*/
public ContextReceiptView receipt(ConsumerPrincipal caller, String intentId) {
if (caller == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
}
String clean = requireLength(intentId, INTENT_MAX, "intentId");
SubmissionContext ctx = contextRepository.findByConsumerAppKeyAndIntentId(caller.getAppKey(), clean)
.orElse(null);
if (ctx == null) {
// 别的应用的意图与不存在的意图同一口径(意图键不是公开坐标,但差别信息仍是不必要的探测面)
throw new BizException(ErrorCode.PARAM_ERROR, "提交上下文不存在:" + clean);
}
accessService.requireRead(caller, ctx.getConsumerSchoolId(), ctx.getFormUuid());
ContextReceiptView view = new ContextReceiptView();
view.setIntentId(ctx.getIntentId());
view.setConsumerAppKey(ctx.getConsumerAppKey());
view.setConsumerSchoolId(ctx.getConsumerSchoolId());
view.setSubmitterId(ctx.getSubmitterId());
view.setFormUuid(ctx.getFormUuid());
view.setFormVersion(ctx.getFormVersion());
view.setExpired(ctx.getExpiresAt() != null && ctx.getExpiresAt().isBefore(LocalDateTime.now()));
view.setSubmittedAt(ctx.getLinkedAt() == null ? null : TS.format(ctx.getLinkedAt()));
if (ctx.getAnswerUuid() == null) {
view.setStatus("PENDING");
return view;
}
Answer answer = answerRepository.findByUuidAndSchId(ctx.getAnswerUuid(), ctx.getConsumerSchoolId())
.orElse(null);
if (answer == null) {
view.setStatus("PENDING");
view.setAnswerUuid(ctx.getAnswerUuid());
return view;
}
view.setStatus("SUBMITTED");
view.setAnswerUuid(answer.getUuid());
view.setAnswerRevision(answer.getAnswerRevision());
view.setContentHash(snapshots.contentHash(answer));
view.setSnapshot(snapshots.forConsumer(answer, caller.getAppKey(), ctx.getConsumerSchoolId()));
return view;
}
/** hosted 页取上下文元数据(精确钉版,不回落当前发布版) */
public SubmissionContext contextOf(String rawToken) {
String token = rawToken == null ? null : rawToken.trim();
if (token == null || token.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "提交上下文无效或已过期");
}
return contextRepository.findByTokenDigest(sha256Hex(token))
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "提交上下文无效或已过期"));
}
// ==================== 内部 ====================
private ContextIssueView issueView(SubmissionContext ctx, String token, boolean reused, Form form) {
ContextIssueView view = new ContextIssueView();
view.setIntentId(ctx.getIntentId());
view.setContextToken(token);
view.setEmbedOrigin(ctx.getEmbedOrigin());
view.setCallbackUrl(ctx.getCallbackUrl());
view.setFormUuid(ctx.getFormUuid());
view.setFormVersion(ctx.getFormVersion());
view.setAppCode(ctx.getAppCode());
view.setCode(ctx.getCode());
view.setFormName(form == null ? null : form.getName());
view.setExpiresAt(ctx.getExpiresAt() == null ? null : TS.format(ctx.getExpiresAt()));
view.setReused(reused);
return view;
}
private void requireSame(SubmissionContext ctx, Long consumerSchoolId, String formUuid,
Integer formVersion, Integer submitterId) {
if (!equalsLong(ctx.getConsumerSchoolId(), consumerSchoolId)
|| !equalsText(ctx.getFormUuid(), formUuid)
|| !equalsInt(ctx.getFormVersion(), formVersion)
|| !equalsInt(ctx.getSubmitterId(), submitterId)) {
throw new BizException(ErrorCode.CUSTOM, "同一 intentId 不得更换表单/学校/填报人:" + ctx.getIntentId());
}
}
private int ttlMinutes() {
Integer ttl = environment.getProperty("form-center.hosted-fill.token-ttl-minutes", Integer.class, 30);
return ttl == null || ttl <= 0 ? 30 : Math.min(ttl, 720);
}
private String requireConfig(String key, String what) {
String value = environment.getProperty(key);
if (value == null || value.trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM,
"服务端配置缺失:" + what + "(键 " + key + ";浏览器传入的任何 URL 都不采信)");
}
return value.trim();
}
private static String requireLength(String raw, int max, String name) {
String value = raw == null ? null : raw.trim();
if (value == null || value.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, name + " 必填");
}
if (value.length() > max) {
throw new BizException(ErrorCode.PARAM_ERROR, name + " 过长(≤" + max + ")");
}
return value;
}
private static boolean equalsText(String left, String right) {
String a = left == null ? null : left.trim();
String b = right == null ? null : right.trim();
return a == null ? b == null : a.equals(b);
}
private static boolean equalsLong(Long a, Long b) {
return a == null ? b == null : a.equals(b);
}
private static boolean equalsInt(Integer a, Integer b) {
return a == null ? b == null : a.equals(b);
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
private static String sha256Hex(String raw) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
return hex(digest.digest(raw.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 不可用(JVM 环境异常)", e);
}
}
}
+40
View File
@@ -53,6 +53,16 @@ spring:
# 实测把上下文启动拖到分钟级——"可选依赖"就名存实亡)。拓扑声明由 FcEventPublisher # 实测把上下文启动拖到分钟级——"可选依赖"就名存实亡)。拓扑声明由 FcEventPublisher
# 自己惰性做一次(exchangeDeclare durable topic,第一次真要发的时候),不依赖 admin。 # 自己惰性做一次(exchangeDeclare durable topic,第一次真要发的时候),不依赖 admin。
dynamic: false dynamic: false
# ===== v2 快照投递的两把可靠性闸(契约 answer-delivery-v2 §3.4,TASK_2 B.4)=====
# correlated:逐条 confirm 回到 AnswerDeliveryService 的回调,只有它才写 broker_accepted_at;
# 拿不到 confirm 就不算"总线收下",行留在 PENDING/FAILED 退避。
# 这两键对 v1 广播通道同样生效:v1 的消息没有 fc-delivery-id 头,回调按头缺席直接忽略。
publisher-confirm-type: ${RABBITMQ_PUBLISHER_CONFIRM_TYPE:correlated}
publisher-returns: ${RABBITMQ_PUBLISHER_RETURNS:true}
template:
# mandatory=true + publisher-returns:路由不到目标私有队列(消费方队列没绑上=部署契约缺项)
# 时必须回来,静默丢弃是最坏结局
mandatory: ${RABBITMQ_TEMPLATE_MANDATORY:true}
# ===== 文件上传存储路径 ===== # ===== 文件上传存储路径 =====
# 教训注释自 notice/service application.yml 原样抄入(2026-10 事故): # 教训注释自 notice/service application.yml 原样抄入(2026-10 事故):
@@ -119,6 +129,36 @@ form-center:
# (CAS cookie 不隔离端口,免二次登录成立的前提);同主域生产置空即整体关闭 # (CAS cookie 不隔离端口,免二次登录成立的前提);同主域生产置空即整体关闭
cors: cors:
allowed-origins: http://localhost:8089 allowed-origins: http://localhost:8089
# ===== hosted 填报(可信提交上下文,answer-delivery-v2 §6,TASK_2 A.2)=====
# 键按**已验签的消费 appKey**取,浏览器传的任何 URL 都不采信:可嵌入 origin 决定被嵌页
# 只允许向哪个精确 origin 发 postMessage,回调基址是消费方确认入口(签发时锁定,作审计与
# 后续反向通知的唯一事实源)。缺键=签发直接失败(fail-closed,宁可不给入口也不给一个能被
# 任意父页面接走的入口)。生产由 env 注入,本文件的本地值仅用于本机联调。
# ⚠️ 本地 svc-hall 后端与 form-center 都写 8086(两应用不会同时本机起,联调时用 env 错开):
# 跨应用联调请显式注入 FC_HOSTED_CALLBACK_URL_SVCHALL 指向 SH 后端实际端口。
hosted-fill:
token-ttl-minutes: ${FC_HOSTED_TOKEN_TTL_MINUTES:30}
embed-origin:
"100018": ${FC_HOSTED_EMBED_ORIGIN_SVCHALL:http://localhost:8087}
callback-url:
"100018": ${FC_HOSTED_CALLBACK_URL_SVCHALL:http://localhost:8086/api/apply/intents}
# ===== v2 快照投递(answer-delivery-v2 §3,TASK_2 B.1~B.6)=====
# 持久性是 outbox 行本身(答卷事务内落库),这里只调"发送侧"的节奏与重试预算。
# source-app-key 默认取本应用签名身份(yh.app-auth.app-key),信封里的 sourceAppKey 由服务端给,
# 不接受消费方申报。未配 broker 时行留在 PENDING、不丢事件(B.4)。
fc:
answer:
delivery:
source-app-key: ${YH_APP_AUTH_APP_KEY:100021}
scan-interval-ms: ${FC_DELIVERY_SCAN_INTERVAL_MS:10000}
scan-initial-delay-ms: ${FC_DELIVERY_SCAN_INITIAL_DELAY_MS:30000}
batch-size: ${FC_DELIVERY_BATCH_SIZE:50}
# 有界重试:8 次×2 递增退避(30s→60s→…→封顶 1h),耗尽即 DEAD 留表待处置,不无限 requeue
max-retries: ${FC_DELIVERY_MAX_RETRIES:8}
backoff-base-seconds: ${FC_DELIVERY_BACKOFF_BASE_SECONDS:30}
backoff-cap-seconds: ${FC_DELIVERY_BACKOFF_CAP_SECONDS:3600}
logging: logging:
level: level:
@@ -1,23 +1,29 @@
package com.yunhai.formcenter.controller.oapi; package com.yunhai.formcenter.controller.oapi;
import com.yunhai.formcenter.common.FormSchemaValidator; import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.model.OapiModels; import com.yunhai.formcenter.model.OapiModels;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.OapiFormService; import com.yunhai.formcenter.service.OapiFormService;
import com.yunhai.platform.appauth.AppAuthFilter;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** /**
* {@code /oapi/form/schema} 的<b>参数绑定</b>单测(T11 W1)。 * {@code /oapi/form/schema} 的<b>参数绑定</b>单测(T11 W1,v3 起带上调用方身份)。
* *
* <p>服务层的分流与口径由 {@code OapiFormServiceIT} 的 F1~F7 断言;这一层只有 MockMvc 能证三件事:</p> * <p>服务层的分流与口径由 {@code OapiFormServiceIT} 的 F1~F7 断言;这一层只有 MockMvc 能证三件事:</p>
* <ul> * <ul>
@@ -26,45 +32,56 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* <li>controller 只做透传、自己不做判断(uuid 优先与 version 单独传的拒绝规则都在服务层,F7 钉住)。</li> * <li>controller 只做透传、自己不做判断(uuid 优先与 version 单独传的拒绝规则都在服务层,F7 钉住)。</li>
* </ul> * </ul>
* *
* <p>用 {@code standaloneSetup} 而不是 {@code @SpringBootTest}:{@code /oapi/*} 的验签墙与统一响应链 * <p>v3(TASK_1)加的两件:身份只从验签属性 {@code yh.app.auth} 取(属性缺失时服务层根本不被调用,
* 是平台链({@code local-debug/t4-read-smoke.sh} 取证),本类只管参数形状,因此也不断言信封包装。</p> * 见 {@code unsignedRequestNeverReachesService}),以及目录端点 {@code /forms} 的分页参数绑定。
* 真实 401 墙是平台链({@code /oapi/*} 由 {@code local-debug/t4-read-smoke.sh} 取证),
* 本类用 {@code standaloneSetup} 直接往请求上放属性,只为证"取哪一处、取不到就拒"。</p>
*/ */
class OapiFormControllerTest { class OapiFormControllerTest {
private static final String CALLER_ATTR = AppAuthFilter.APP_AUTH_ATTR;
private static final ConsumerPrincipal CALLER = new ConsumerPrincipal("100018");
private OapiFormService formService; private OapiFormService formService;
private MockMvc mvc; private MockMvc mvc;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
formService = mock(OapiFormService.class); formService = mock(OapiFormService.class);
mvc = MockMvcBuilders.standaloneSetup(new OapiFormController(formService)).build(); // 密钥注册表用桩替:只认 100018 这一个 appKey,其它一律"未在平台注册"
ConsumerPrincipalResolver resolver = new ConsumerPrincipalResolver(
appKey -> "100018".equals(appKey) ? "svc-hall-dev-secret-100018" : null);
mvc = MockMvcBuilders.standaloneSetup(new OapiFormController(formService, resolver)).build();
} }
@Test @Test
@DisplayName("uuid 形态不带 code:绑定通过(不 400),code/appCode 以 null 传给服务") @DisplayName("uuid 形态不带 code:绑定通过(不 400),code/appCode 以 null 传给服务")
void uuidFormNeedsNoCode() throws Exception { void uuidFormNeedsNoCode() throws Exception {
when(formService.schema(1001L, null, null, "t11uuid000000000000000000000000ab", null)) when(formService.schema(CALLER, 1001L, null, null, "t11uuid000000000000000000000000ab", null))
.thenReturn(view("t11uuid000000000000000000000000ab", 2)); .thenReturn(view("t11uuid000000000000000000000000ab", 2));
mvc.perform(get("/oapi/form/schema").param("schId", "1001") mvc.perform(get("/oapi/form/schema").param("schId", "1001")
.param("uuid", "t11uuid000000000000000000000000ab")) .param("uuid", "t11uuid000000000000000000000000ab")
.requestAttr(CALLER_ATTR, "100018"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.formUuid").value("t11uuid000000000000000000000000ab")) .andExpect(jsonPath("$.formUuid").value("t11uuid000000000000000000000000ab"))
.andExpect(jsonPath("$.version").value(2)); .andExpect(jsonPath("$.version").value(2));
verify(formService).schema(1001L, null, null, "t11uuid000000000000000000000000ab", null); verify(formService).schema(CALLER, 1001L, null, null, "t11uuid000000000000000000000000ab", null);
} }
@Test @Test
@DisplayName("uuid+version 形态:version 绑成 Integer=3;不给时是 null 而不是 0") @DisplayName("uuid+version 形态:version 绑成 Integer=3;不给时是 null 而不是 0")
void versionBindsAsIntegerAndDefaultsToNull() throws Exception { void versionBindsAsIntegerAndDefaultsToNull() throws Exception {
when(formService.schema(1001L, null, null, "u", 3)).thenReturn(view("u", 3)); when(formService.schema(CALLER, 1001L, null, null, "u", 3)).thenReturn(view("u", 3));
mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u").param("version", "3")) mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u").param("version", "3")
.requestAttr(CALLER_ATTR, "100018"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.version").value(3)); .andExpect(jsonPath("$.version").value(3));
when(formService.schema(1001L, null, null, "u", null)).thenReturn(view("u", 7)); when(formService.schema(CALLER, 1001L, null, null, "u", null)).thenReturn(view("u", 7));
mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u")) mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u")
.requestAttr(CALLER_ATTR, "100018"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.version").value(7)); .andExpect(jsonPath("$.version").value(7));
} }
@@ -72,28 +89,54 @@ class OapiFormControllerTest {
@Test @Test
@DisplayName("code 形态五参全给:controller 原样透传,不做任何取舍(取舍规则在服务层)") @DisplayName("code 形态五参全给:controller 原样透传,不做任何取舍(取舍规则在服务层)")
void codeFormPassesAllFiveArgumentsThrough() throws Exception { void codeFormPassesAllFiveArgumentsThrough() throws Exception {
when(formService.schema(1001L, "ysch-flow", "honor_student", null, null)).thenReturn(view("u9", 1)); when(formService.schema(CALLER, 1001L, "ysch-flow", "honor_student", null, null)).thenReturn(view("u9", 1));
mvc.perform(get("/oapi/form/schema").param("schId", "1001") mvc.perform(get("/oapi/form/schema").param("schId", "1001")
.param("code", "honor_student").param("appCode", "ysch-flow")) .param("code", "honor_student").param("appCode", "ysch-flow")
.requestAttr(CALLER_ATTR, "100018"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.groups[0].key").value("base")) .andExpect(jsonPath("$.groups[0].key").value("base"))
.andExpect(jsonPath("$.audience").value("STUDENT")); .andExpect(jsonPath("$.audience").value("STUDENT"));
verify(formService).schema(1001L, "ysch-flow", "honor_student", null, null); verify(formService).schema(CALLER, 1001L, "ysch-flow", "honor_student", null, null);
} }
@Test @Test
@DisplayName("uuid 与 code 同传:两个值都原样交给服务(uuid 优先由服务层判,见 IT F7)") @DisplayName("uuid 与 code 同传:两个值都原样交给服务(uuid 优先由服务层判,见 IT F7)")
void bothKeysAreForwarded() throws Exception { void bothKeysAreForwarded() throws Exception {
when(formService.schema(1001L, null, "honor_student", "u-first", null)).thenReturn(view("u-first", 1)); when(formService.schema(CALLER, 1001L, null, "honor_student", "u-first", null)).thenReturn(view("u-first", 1));
mvc.perform(get("/oapi/form/schema").param("schId", "1001") mvc.perform(get("/oapi/form/schema").param("schId", "1001")
.param("code", "honor_student").param("uuid", "u-first")) .param("code", "honor_student").param("uuid", "u-first")
.requestAttr(CALLER_ATTR, "100018"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.formUuid").value("u-first")); .andExpect(jsonPath("$.formUuid").value("u-first"));
verify(formService).schema(1001L, null, "honor_student", "u-first", null); verify(formService).schema(CALLER, 1001L, null, "honor_student", "u-first", null);
}
@Test
@DisplayName("目录端点:schId/appCode/keyword/page/pageSize 原样透传给服务层")
void catalogPassesFilterAndPagingThrough() throws Exception {
when(formService.catalog(CALLER, 1001L, "svc-hall", "荣誉", 2, 50))
.thenReturn(new com.yunhai.formcenter.model.FormAccessModels.CatalogPage());
mvc.perform(get("/oapi/form/forms").param("schId", "1001").param("appCode", "svc-hall")
.param("keyword", "荣誉").param("page", "2").param("pageSize", "50")
.requestAttr(CALLER_ATTR, "100018"))
.andExpect(status().isOk());
verify(formService).catalog(CALLER, 1001L, "svc-hall", "荣誉", 2, 50);
}
@Test
@DisplayName("请求没带验签属性:直接拒,服务层一次都不被调用(身份不从查询参数里取)")
void unsignedRequestNeverReachesService() {
Throwable thrown = catchThrowable(() -> mvc.perform(get("/oapi/form/schema")
.param("schId", "1001").param("uuid", "u")));
assertThat(thrown).isNotNull();
verifyNoInteractions(formService);
} }
private static OapiModels.SchemaView view(String uuid, int version) { private static OapiModels.SchemaView view(String uuid, int version) {
@@ -0,0 +1,846 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerDelivery;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormVersion;
import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReport;
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.AnswerDeliveryModels.GrantSnapshotView;
import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReportView;
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.formcenter.repository.SubmissionContextRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.env.Environment;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.TreeSet;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 快照投递 v2 与可信提交上下文(answer-delivery-v2,TASK_2 A/B 段规则单测)。
*
* <p>取向沿 {@code FcDiscoveryAccessTest}:不打真实库、不起 Spring 上下文,把协作者喂成桩,
* 规则一改立刻红。<b>证据边界要说明白</b>:本类能证明"答卷写与 outbox 写落在同一个事务回调内、
* 回调抛出后两者都不落地、afterCommit 只唤醒扫描",但桩事务证不了 MySQL 的原子性——
* 真库回滚属于打真库的 {@code Answer*IT} 那一层,本机无 mysqld 时不跑(交付说明"证据边界"同条)。</p>
*
* <p>跨端夹具({@link #canonicalVectorIsByteExact()})是本契约唯一允许两端各抄一份的实现事实:
* 规范串写死在字面量里,SH 侧 {@code ShAnswerFlowTest} 钉<b>同一个字面量</b>,
* 任何一侧改动都会同时红在两个仓。</p>
*/
@DisplayName("答卷快照投递 v2 与可信提交上下文 - 规则单测(TASK_2)")
class FcAnswerDeliveryTest {
private static final String CONSUMER_APP_KEY = "100018";
private static final String OTHER_APP_KEY = "100020";
private static final String SOURCE_APP_KEY = "100021";
private static final long SCH = 5L;
private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f";
private static final String ANSWER_UUID = "a1b2c3d4e5f60718293a4b5c6d7e8f90";
private static final String INTENT_ID = "svc-hall-intent-0001";
private static final String TOKEN = "0123456789abcdef0123456789abcdef01234567";
/**
* fc.canon.v1 跨端夹具:键序=UTF-16 代码元升序、数组保序、数字摊平十进制、显式 null 保留。
* 快照成员闭集就是这十个(answerNo/updateTime 为 null 也恒出现),SH 侧
* {@code ShAnswerFlowTest} 必须逐字复用同一串。
*/
private static final String CANONICAL_VECTOR =
"{\"answerNo\":null,\"appCode\":\"demo\",\"code\":\"honor_apply\","
+ "\"createTime\":\"2026-10-21T10:00:00\",\"schId\":5,\"status\":\"processed\","
+ "\"subjectId\":7,\"submitterId\":1001,\"updateTime\":null,"
+ "\"values\":{\"a\":{\"y\":null,\"z\":1},\"b\":[2,1],\"e\":true,\"n\":100}}";
private ObjectMapper mapper;
@BeforeEach
void setUp() {
mapper = new ObjectMapper();
}
// ==================== 规范化与信封 ====================
@Test
@DisplayName("fc.canon.v1:规范串逐字比对(键序/数组保序/数字摊平/显式 null 保留/intent 不参与哈希)")
void canonicalVectorIsByteExact() {
Answer answer = answer(1L, "{\"a\":{\"z\":1,\"y\":null},\"b\":[2,1],\"n\":100.0,\"e\":true}",
"processed");
answer.setCreateTime(LocalDateTime.of(2026, 10, 21, 10, 0, 0));
answer.setUpdateTime(null);
answer.setAnswerNo(null);
answer.setSubjectId(7);
AnswerSnapshotService snapshots = snapshotService(mock(AnswerRepository.class),
mock(SubmissionContextRepository.class), mock(FormConsumerAccessService.class));
// 时间形态被钉成 yyyy-MM-dd'T'HH:mm:ss:ISO_LOCAL_DATE_TIME 会在整分时省掉秒段,跨端就比不了
assertThat(snapshots.canonicalCore(answer)).isEqualTo(CANONICAL_VECTOR);
// 同 revision 只能有一份 hash:intent 关联块只进载荷、不进哈希
ObjectNode withIntent = mapper.valueToTree(snapshots.core(answer));
withIntent.set("intent", mapper.createObjectNode().put("intentId", INTENT_ID));
assertThat(AnswerCanonicalizer.hashSnapshot(withIntent))
.isEqualTo(AnswerCanonicalizer.hashSnapshot(mapper.valueToTree(snapshots.core(answer))));
assertThat(AnswerCanonicalizer.hashSnapshot(withIntent))
.isEqualTo(snapshots.contentHash(answer));
}
@Test
@DisplayName("contentHash=规范串 UTF-8 字节的 SHA-256 小写十六进制(64 位,同输入稳定)")
void hashIsSha256OverCanonicalBytes() {
ObjectNode snapshot = mapper.createObjectNode();
snapshot.set("values", mapper.createObjectNode());
snapshot.put("schId", 5L);
String expected = sha256HexOf(AnswerCanonicalizer.canonical(snapshot));
assertThat(AnswerCanonicalizer.hashSnapshot(snapshot)).isEqualTo(expected).hasSize(64);
assertThat(AnswerCanonicalizer.hashSnapshot(snapshot)).isEqualTo(expected);
}
@Test
@DisplayName("v2 信封是契约点名的字段闭集,且 exchange 与 v1 广播通道物理分离")
void envelopeIsTheClosedFieldSet() {
TreeSet<String> names = new TreeSet<>();
for (Field field : SnapshotEnvelope.class.getDeclaredFields()) {
if (!field.isSynthetic()) {
names.add(field.getName());
}
}
assertThat(names).containsExactly(
"answerRevision", "answerUuid", "consumerAppKey", "consumerSchoolId", "contentHash",
"eventId", "eventType", "formUuid", "formVersion", "grantVersion", "isDeleted",
"occurredAt", "protocolVersion", "snapshot", "sourceAppKey");
assertThat(AnswerDeliveryModels.PROTOCOL_VERSION).isEqualTo("fc.answer.snapshot.v2");
assertThat(AnswerDeliveryModels.EXCHANGE).isEqualTo("fc.answer.delivery.v2")
.isNotEqualTo(FcEventPublisher.EXCHANGE);
}
// ==================== 入队与取号 ====================
@Test
@DisplayName("入队按显式消费 appKey+租户域各一行;目标不取资源 appCode;同答案各行同 hash")
void enqueueUsesExplicitConsumerTarget() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(
java.util.Arrays.asList(grant(OTHER_APP_KEY, SCH, 3), grant(CONSUMER_APP_KEY, SCH, 7)));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
assertThat(service.enqueueInTransaction(answer(1L, "{\"k\":\"v\"}", "draft"),
AnswerDeliveryModels.EVENT_CREATED)).isEqualTo(2);
List<AnswerDelivery> saved = captureDeliveries(rows);
assertThat(saved).hasSize(2);
assertThat(saved).extracting(AnswerDelivery::getConsumerAppKey)
.containsExactlyInAnyOrder(OTHER_APP_KEY, CONSUMER_APP_KEY);
assertThat(saved).allSatisfy(row -> {
assertThat(row.getProtocolVersion()).isEqualTo(AnswerDeliveryModels.PROTOCOL_VERSION);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_PENDING);
assertThat(row.getAnswerRevision()).isEqualTo(1L);
assertThat(row.getFormUuid()).isEqualTo(FORM_UUID);
assertThat(row.getFormVersion()).isEqualTo(4);
assertThat(row.getContentHash()).hasSize(64);
assertThat(row.getEventId()).isNotBlank();
assertThat(row.getSnapshotJson()).isNotBlank();
});
assertThat(saved.get(0).getContentHash()).isEqualTo(saved.get(1).getContentHash());
assertThat(AnswerDeliveryService.routingKey(CONSUMER_APP_KEY, SCH))
.isEqualTo("fc.snapshot.v2." + CONSUMER_APP_KEY + "." + SCH);
}
@Test
@DisplayName("无订阅的简单表单:零投递行,答卷照常提交(B.2 末句)")
void noSubscriptionMeansNoDeliveryRows() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(anyString(), anyLong())).thenReturn(Collections.emptyList());
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
assertThat(service.enqueueInTransaction(answer(1L, "{}", "draft"),
AnswerDeliveryModels.EVENT_CREATED)).isZero();
verify(rows, never()).saveAndFlush(any());
}
@Test
@DisplayName("取号=行级 CAS:未命中即拒且不改内存态;命中才推进(同 revision 两份 hash 的入口被关掉)")
void nextRevisionUsesRowCasAndRejectsMiss() {
AnswerRepository answers = mock(AnswerRepository.class);
when(answers.compareAndSetRevision(anyLong(), anyLong(), anyLong())).thenReturn(0);
AnswerDeliveryService service = service(mock(AnswerDeliveryRepository.class), answers,
mock(FormConsumerAccessService.class), null);
Answer answer = answer(4L, "{}", "draft");
assertThatThrownBy(() -> service.nextRevision(answer)).isInstanceOf(BizException.class)
.hasMessageContaining("CAS 未命中");
assertThat(answer.getAnswerRevision()).isEqualTo(4L);
when(answers.compareAndSetRevision(9001L, 4L, 5L)).thenReturn(1);
assertThat(service.nextRevision(answer)).isEqualTo(5L);
assertThat(answer.getAnswerRevision()).isEqualTo(5L);
}
@Test
@DisplayName("并发撞 outbox 唯一键转业务异常:不许漏给提交链路的幂等分支伪装成重放成功")
void deliveryUkCollisionIsBusinessError() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
when(rows.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("uk_delivery_revision"));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(
Collections.singletonList(grant(CONSUMER_APP_KEY, SCH, 1)));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
assertThatThrownBy(() -> service.enqueueInTransaction(answer(2L, "{}", "draft"),
AnswerDeliveryModels.EVENT_UPDATED)).isInstanceOf(BizException.class)
.hasMessageContaining("并发修改冲突");
}
// ==================== 发送侧:撤权 / confirm / 退避 ====================
@Test
@DisplayName("发布前再核 grant:撤销或纪元进位都不重放旧授权(B.3、v2 §5 撤权边界)")
void revokedGrantStopsNewDelivery() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery pending = deliveryRow(AnswerDelivery.STATE_PENDING, 1L, 7);
when(rows.findDue(any(LocalDateTime.class), any())).thenReturn(Collections.singletonList(pending));
when(rows.findById(pending.getId())).thenReturn(Optional.of(pending));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(Collections.emptyList());
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
withBroker(service);
assertThat(service.sendDueBatch()).isZero();
assertThat(pending.getState()).isEqualTo(AnswerDelivery.STATE_GRANT_REVOKED);
}
@Test
@DisplayName("confirm 只写 brokerAccepted;consumerApplied 只能由签名回执写(B.4/B.6)")
void brokerConfirmIsNotBusinessSuccess() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery row = deliveryRow(AnswerDelivery.STATE_PENDING, 1L, 7);
when(rows.findById(row.getId())).thenReturn(Optional.of(row));
when(rows.findByEventId(row.getEventId())).thenReturn(Optional.of(row));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class),
mock(FormConsumerAccessService.class), null);
service.onBrokerConfirm(row.getId(), true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_BROKER_ACCEPTED);
assertThat(row.getBrokerAcceptedAt()).isNotNull();
assertThat(row.getConsumerAppliedAt()).isNull();
AppliedReport report = report(row);
report.setOutcome(AnswerDelivery.OUTCOME_APPLIED);
report.setContentHash(chars('a'));
AppliedReportView view = service.recordApplied(report);
// 回执自称 APPLIED 但 hash 与信封不符 → 记 CONFLICT:声称成功不等于成功
assertThat(view.getState()).isEqualTo(AnswerDelivery.OUTCOME_CONFLICT);
assertThat(row.getConsumerAppliedAt()).isNotNull();
}
@Test
@DisplayName("回执逐键复核与结论幂等:任一坐标不符即拒,重复同结论不改写、不同结论保留首次")
void receiptMustMatchEveryKey() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery row = deliveryRow(AnswerDelivery.STATE_BROKER_ACCEPTED, 1L, 7);
row.setContentHash(chars('b'));
row.setConsumerOutcome(AnswerDelivery.OUTCOME_APPLIED);
row.setConsumerAppliedAt(LocalDateTime.now());
when(rows.findByEventId(row.getEventId())).thenReturn(Optional.of(row));
when(rows.findById(row.getId())).thenReturn(Optional.of(row));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class),
mock(FormConsumerAccessService.class), null);
AppliedReport wrongRevision = report(row);
wrongRevision.setAnswerRevision(99L);
assertThatThrownBy(() -> service.recordApplied(wrongRevision)).isInstanceOf(BizException.class)
.hasMessageContaining("不匹配");
AppliedReport sameConclusion = report(row);
sameConclusion.setContentHash(row.getContentHash());
AppliedReportView view = service.recordApplied(sameConclusion);
assertThat(view.getState()).isEqualTo(AnswerDelivery.OUTCOME_APPLIED);
assertThat(view.getDuplicate()).isTrue();
AppliedReport otherOutcome = report(row);
otherOutcome.setOutcome(AnswerDelivery.OUTCOME_IGNORED);
assertThat(service.recordApplied(otherOutcome).getState())
.isEqualTo(AnswerDelivery.OUTCOME_APPLIED);
}
@Test
@DisplayName("未配 broker:行留在 PENDING 且仍可观察,不 no-op 丢事件(B.4)")
void pendingRowsSurviveWithoutBroker() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
when(rows.saveAndFlush(any())).thenAnswer(call -> call.getArgument(0));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), grants(),
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), grants()));
service.enqueueAndWake(answer(1L, "{\"k\":\"v\"}", "draft"), AnswerDeliveryModels.EVENT_CREATED);
List<AnswerDelivery> saved = captureDeliveries(rows);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).getState()).isEqualTo(AnswerDelivery.STATE_PENDING);
assertThat(saved.get(0).getNextRetryAt()).isNotNull();
assertThat(service.sendDueBatchQuietly()).isZero();
verify(rows, never()).findDue(any(LocalDateTime.class), any());
}
@Test
@DisplayName("发送异常走有界退避,重试耗尽转 DEAD(不无限重投热循环)")
void sendFailuresBackOffThenDie() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery row = deliveryRow(AnswerDelivery.STATE_FAILED, 1L, 7);
row.setRetryCount(7);
when(rows.findDue(any(LocalDateTime.class), any())).thenReturn(Collections.singletonList(row));
when(rows.findById(row.getId())).thenReturn(Optional.of(row));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(
Collections.singletonList(grant(CONSUMER_APP_KEY, SCH, 7)));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
// broker 已配但容器里没有 template(ObjectProvider 给 null)=发送侧异常路径
withBroker(service);
assertThat(service.sendDueBatch()).isZero();
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_DEAD);
assertThat(row.getNextRetryAt()).isNull();
assertThat(row.getRetryCount()).isEqualTo(8);
}
@Test
@DisplayName("broker nack 走退避而不是记成功;已 BROKER_ACCEPTED 的行不被重复 confirm 改写")
void nackBacksOffAndDuplicateConfirmIsIdempotent() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery row = deliveryRow(AnswerDelivery.STATE_PENDING, 1L, 7);
when(rows.findById(row.getId())).thenReturn(Optional.of(row));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class),
mock(FormConsumerAccessService.class), null);
service.onBrokerConfirm(row.getId(), false, "channel closed");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getNextRetryAt()).isAfter(LocalDateTime.now().minusSeconds(1));
assertThat(row.getRetryCount()).isEqualTo(1);
row.setState(AnswerDelivery.STATE_BROKER_ACCEPTED);
row.setBrokerAcceptedAt(LocalDateTime.now());
service.onBrokerConfirm(row.getId(), false, "迟到的 nack");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_BROKER_ACCEPTED);
}
// ==================== TASK_3 扩展点 ====================
@Test
@DisplayName("replayAuthorized 稳定状态码:无行/期望版本不符/已送达/重新入队/撤权/同操作幂等")
void replayAuthorizedHasStableStates() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
AnswerDelivery row = deliveryRow(AnswerDelivery.STATE_BROKER_ACCEPTED, 3L, 7);
when(rows.findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(ANSWER_UUID, CONSUMER_APP_KEY))
.thenReturn(Collections.singletonList(row));
when(rows.findById(row.getId())).thenReturn(Optional.of(row));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(
Collections.singletonList(grant(CONSUMER_APP_KEY, SCH, 7)));
AnswerDeliveryService service = service(rows, mock(AnswerRepository.class), access,
snapshotService(mock(AnswerRepository.class), mock(SubmissionContextRepository.class), access));
ConsumerPrincipal caller = new ConsumerPrincipal(CONSUMER_APP_KEY);
assertThat(service.replayAuthorized(caller, ANSWER_UUID, 9L, "op-1").getState())
.isEqualTo(AnswerDeliveryService.STATE_REVISION_MISMATCH);
assertThat(service.replayAuthorized(caller, "ffffffffffffffffffffffffffffffff", 1L, "op-1").getState())
.isEqualTo(AnswerDeliveryService.STATE_NO_DELIVERY_ROW);
row.setConsumerAppliedAt(LocalDateTime.now());
row.setConsumerOutcome(AnswerDelivery.OUTCOME_APPLIED);
assertThat(service.replayAuthorized(caller, ANSWER_UUID, 3L, "op-2").getState())
.isEqualTo(AnswerDeliveryService.STATE_ALREADY_DELIVERED);
row.setConsumerAppliedAt(null);
row.setConsumerOutcome(null);
assertThat(service.replayAuthorized(caller, ANSWER_UUID, 3L, "op-3").getState())
.isEqualTo(AnswerDeliveryService.STATE_QUEUED);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_PENDING);
row.setState(AnswerDelivery.STATE_FAILED);
assertThat(service.replayAuthorized(caller, ANSWER_UUID, 3L, "op-3").getState())
.isEqualTo(AnswerDeliveryService.STATE_QUEUED);
// 同 operationId 幂等吸收:不再刷新退避(行仍是上次入队后的状态)
assertThat(row.getNextRetryAt()).isNotNull();
row.setState(AnswerDelivery.STATE_BROKER_ACCEPTED);
row.setLastReplayOperationId(null);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(Collections.emptyList());
assertThat(service.replayAuthorized(caller, ANSWER_UUID, 3L, "op-4").getState())
.isEqualTo(AnswerDeliveryService.STATE_GRANT_REVOKED);
}
@Test
@DisplayName("轻量授权核验:判据仍走 TASK_1 的 requireSubscribe,不回查答案值文档")
void grantVerifyUsesTaskOneServiceOnly() {
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.requireSubscribe(any(), eq(SCH), eq(FORM_UUID)))
.thenReturn(grant(CONSUMER_APP_KEY, SCH, 4));
when(access.requireSubscribe(any(), eq(SCH), eq("other-uuid")))
.thenThrow(new BizException(ErrorCode.PARAM_ERROR, "表单不存在:other-uuid"));
AnswerDeliveryService service = service(mock(AnswerDeliveryRepository.class),
mock(AnswerRepository.class), access, null);
List<GrantSnapshotView> out = service.grantSnapshot(new ConsumerPrincipal(CONSUMER_APP_KEY), SCH,
java.util.Arrays.asList(FORM_UUID, "other-uuid"));
assertThat(out).hasSize(2);
assertThat(out.get(0).getSubscribeAllowed()).isTrue();
assertThat(out.get(0).getGrantVersion()).isEqualTo(4);
assertThat(out.get(1).getSubscribeAllowed()).isFalse();
assertThat(out.get(1).getGrantVersion()).isZero();
}
// ==================== 可信提交上下文 ====================
@Test
@DisplayName("token 错人/错校/过期/不存在各归一拒;不存在与无效同一句文案(不给探测面)")
void tokenRejectsWrongPersonSchoolAndExpiry() {
SubmissionContextRepository contexts = mock(SubmissionContextRepository.class);
SubmissionContext ctx = context(LocalDateTime.now().plusMinutes(10));
ctx.setTokenDigest(sha256HexOf(TOKEN));
when(contexts.findByTokenDigest(ctx.getTokenDigest())).thenReturn(Optional.of(ctx));
SubmissionContextService service = contextService(contexts, mock(FormConsumerAccessService.class),
mock(FormDefService.class), mock(AnswerSnapshotService.class));
// 不存在的令牌与"存在但不属于你"同一句
assertThatThrownBy(() -> service.redeem("ffffffffffffffffffffffffffffffffffffffff",
sessionUser(1001, (int) SCH), SCH))
.isInstanceOf(BizException.class).hasMessage("提交上下文无效或已过期");
assertThatThrownBy(() -> service.redeem(" ", sessionUser(1001, (int) SCH), SCH))
.isInstanceOf(BizException.class).hasMessage("提交上下文无效或已过期");
assertThatThrownBy(() -> service.redeem(TOKEN, sessionUser(2002, (int) SCH), SCH))
.isInstanceOf(BizException.class).hasMessageContaining("错人");
assertThatThrownBy(() -> service.redeem(TOKEN, sessionUser(1001, 6), 6L))
.isInstanceOf(BizException.class).hasMessageContaining("错校");
ctx.setExpiresAt(LocalDateTime.now().minusMinutes(1));
assertThatThrownBy(() -> service.redeem(TOKEN, sessionUser(1001, (int) SCH), SCH))
.isInstanceOf(BizException.class).hasMessageContaining("已过期");
ctx.setExpiresAt(LocalDateTime.now().plusMinutes(10));
assertThat(service.redeem(TOKEN, sessionUser(1001, (int) SCH), SCH).getIntentId()).isEqualTo(INTENT_ID);
}
@Test
@DisplayName("签发:read+subscribe 两把都过;同意图重试回到同一行;已关联后不再发新入口")
void issueIsIdempotentPerIntent() {
SubmissionContextRepository contexts = mock(SubmissionContextRepository.class);
when(contexts.saveAndFlush(any())).thenAnswer(call -> call.getArgument(0));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.requireSubscribe(any(), eq(SCH), eq(FORM_UUID))).thenReturn(grant(CONSUMER_APP_KEY, SCH, 2));
FormDefService defService = mock(FormDefService.class);
when(defService.resolvePublishedByUuid(SCH, FORM_UUID, 4))
.thenReturn(pinned(publishedForm(), versionRow(4)));
SubmissionContextService service = contextService(contexts, access, defService,
mock(AnswerSnapshotService.class));
ContextIssueView first = service.issue(new ConsumerPrincipal(CONSUMER_APP_KEY), issueRequest());
assertThat(first.getContextToken()).hasSize(40);
assertThat(first.getReused()).isFalse();
assertThat(first.getEmbedOrigin()).isEqualTo("http://localhost:8087");
assertThat(first.getCallbackUrl()).isEqualTo("http://svc-hall.local/api/apply/intents");
verify(access).requireRead(any(), eq(SCH), eq(FORM_UUID));
verify(access).requireSubscribe(any(), eq(SCH), eq(FORM_UUID));
SubmissionContext stored = captureContext(contexts);
when(contexts.findByConsumerAppKeyAndIntentId(CONSUMER_APP_KEY, INTENT_ID))
.thenReturn(Optional.of(stored));
ContextIssueView retry = service.issue(new ConsumerPrincipal(CONSUMER_APP_KEY), issueRequest());
assertThat(retry.getReused()).isTrue();
assertThat(retry.getIntentId()).isEqualTo(INTENT_ID);
assertThat(retry.getContextToken()).isNotEqualTo(first.getContextToken());
// 同一行:没有第二条业务意图(两次签发各 save 一次,仓库侧唯一键才最终兜底)
verify(contexts, times(2)).saveAndFlush(any());
stored.setAnswerUuid(ANSWER_UUID);
assertThatThrownBy(() -> service.issue(new ConsumerPrincipal(CONSUMER_APP_KEY), issueRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("已关联答卷");
}
@Test
@DisplayName("签发 fail-closed:无订阅授权 / 缺可嵌入 origin / 未验签都不给上下文")
void issueFailsClosed() {
SubmissionContextRepository contexts = mock(SubmissionContextRepository.class);
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.requireSubscribe(any(), any(), any()))
.thenThrow(new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + FORM_UUID));
SubmissionContextService service = contextService(contexts, access, mock(FormDefService.class),
mock(AnswerSnapshotService.class));
assertThatThrownBy(() -> service.issue(new ConsumerPrincipal(CONSUMER_APP_KEY), issueRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("表单不存在");
verify(contexts, never()).saveAndFlush(any());
// 未验签(caller=null):结构上不给身份,直接拒
assertThatThrownBy(() -> service.issue(null, issueRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("验签");
// 缺 origin 配置:即使授权与坐标都对,也不签发(不采信浏览器 URL)
FormConsumerAccessService granted = mock(FormConsumerAccessService.class);
when(granted.requireSubscribe(any(), any(), any())).thenReturn(grant(CONSUMER_APP_KEY, SCH, 1));
FormDefService defService = mock(FormDefService.class);
when(defService.resolvePublishedByUuid(SCH, FORM_UUID, 4)).thenReturn(pinned(publishedForm(), versionRow(4)));
Environment blank = mock(Environment.class);
SubmissionContextService unconfigured = new SubmissionContextService(contexts,
mock(AnswerRepository.class), granted, defService, mock(AnswerSnapshotService.class), blank);
assertThatThrownBy(() -> unconfigured.issue(new ConsumerPrincipal(CONSUMER_APP_KEY), issueRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("可嵌入 origin 未登记");
}
@Test
@DisplayName("原子关联:一份答卷只挂一个意图;同 token 重放同一份答卷,换答卷重用即拒")
void linkGuardsAnswerReuse() {
SubmissionContextRepository contexts = mock(SubmissionContextRepository.class);
when(contexts.saveAndFlush(any())).thenAnswer(call -> call.getArgument(0));
SubmissionContextService service = new SubmissionContextService(contexts,
mock(AnswerRepository.class), mock(FormConsumerAccessService.class),
mock(FormDefService.class), mock(AnswerSnapshotService.class), mock(Environment.class));
SubmissionContext ctx = context(LocalDateTime.now().plusMinutes(10));
Answer answer = answer(1L, "{}", "draft");
service.link(ctx, answer);
assertThat(ctx.getAnswerUuid()).isEqualTo(ANSWER_UUID);
assertThat(ctx.getLinkedRevision()).isEqualTo(1L);
assertThat(ctx.getLinkedAt()).isNotNull();
service.link(ctx, answer);
assertThat(ctx.getAnswerUuid()).isEqualTo(ANSWER_UUID);
Answer second = answer(1L, "{}", "draft");
second.setUuid("99999999999999999999999999999999");
assertThatThrownBy(() -> service.link(ctx, second)).isInstanceOf(BizException.class)
.hasMessageContaining("不能重复使用");
}
@Test
@DisplayName("坐标不符即拒:令牌的 appCode/code 与提交体坐标逐字比对")
void assertCoordinatesMustMatchToken() {
SubmissionContextService service = new SubmissionContextService(
mock(SubmissionContextRepository.class), mock(AnswerRepository.class),
mock(FormConsumerAccessService.class), mock(FormDefService.class),
mock(AnswerSnapshotService.class), mock(Environment.class));
SubmissionContext ctx = context(LocalDateTime.now().plusMinutes(10));
service.assertCoordinates(ctx, "demo", "honor_apply");
assertThatThrownBy(() -> service.assertCoordinates(ctx, "demo", "other_code"))
.isInstanceOf(BizException.class).hasMessageContaining("坐标与本次提交不符");
}
@Test
@DisplayName("收据只向所属消费应用返回,读取时再核一次 read;未关联出 PENDING、已关联带坐标与 hash")
void receiptOnlyForOwningConsumer() {
SubmissionContextRepository contexts = mock(SubmissionContextRepository.class);
AnswerRepository answers = mock(AnswerRepository.class);
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
AnswerSnapshotService snapshots = mock(AnswerSnapshotService.class);
when(snapshots.contentHash(any())).thenReturn(chars('c'));
SubmissionContextService service = new SubmissionContextService(contexts, answers, access,
mock(FormDefService.class), snapshots, mock(Environment.class));
SubmissionContext ctx = context(LocalDateTime.now().plusMinutes(10));
when(contexts.findByConsumerAppKeyAndIntentId(CONSUMER_APP_KEY, INTENT_ID))
.thenReturn(Optional.of(ctx));
assertThatThrownBy(() -> service.receipt(new ConsumerPrincipal(OTHER_APP_KEY), INTENT_ID))
.isInstanceOf(BizException.class).hasMessageContaining("提交上下文不存在");
ContextReceiptView pending = service.receipt(new ConsumerPrincipal(CONSUMER_APP_KEY), INTENT_ID);
assertThat(pending.getStatus()).isEqualTo("PENDING");
assertThat(pending.getAnswerUuid()).isNull();
ctx.setAnswerUuid(ANSWER_UUID);
ctx.setLinkedRevision(1L);
when(answers.findByUuidAndSchId(ANSWER_UUID, SCH)).thenReturn(Optional.of(answer(1L, "{}", "processed")));
ContextReceiptView submitted = service.receipt(new ConsumerPrincipal(CONSUMER_APP_KEY), INTENT_ID);
assertThat(submitted.getStatus()).isEqualTo("SUBMITTED");
assertThat(submitted.getAnswerUuid()).isEqualTo(ANSWER_UUID);
assertThat(submitted.getAnswerRevision()).isEqualTo(1L);
assertThat(submitted.getContentHash()).isEqualTo(chars('c'));
verify(access, times(2)).requireRead(any(), eq(SCH), eq(FORM_UUID));
}
// ==================== 夹具 ====================
private static SubscriptionGrant grant(String appKey, Long schoolId, int grantVersion) {
SubscriptionGrant grant = new SubscriptionGrant();
grant.setFormUuid(FORM_UUID);
grant.setConsumerAppKey(appKey);
grant.setConsumerSchoolId(schoolId);
grant.setReadAllowed(true);
grant.setSubscribeAllowed(true);
grant.setGrantVersion(grantVersion);
return grant;
}
private static AppliedReport report(AnswerDelivery row) {
AppliedReport report = new AppliedReport();
report.setEventId(row.getEventId());
report.setConsumerAppKey(row.getConsumerAppKey());
report.setConsumerSchoolId(row.getConsumerSchoolId());
report.setAnswerUuid(row.getAnswerUuid());
report.setAnswerRevision(row.getAnswerRevision());
report.setOutcome(AnswerDelivery.OUTCOME_APPLIED);
return report;
}
private AnswerDelivery deliveryRow(String state, Long revision, int grantVersion) {
AnswerDelivery row = new AnswerDelivery();
row.setId(501L);
row.setEventId("evt-" + revision + "-" + grantVersion);
row.setProtocolVersion(AnswerDeliveryModels.PROTOCOL_VERSION);
row.setEventType(AnswerDeliveryModels.EVENT_UPDATED);
row.setSourceAppKey(SOURCE_APP_KEY);
row.setConsumerAppKey(CONSUMER_APP_KEY);
row.setConsumerSchoolId(SCH);
row.setGrantVersion(grantVersion);
row.setFormUuid(FORM_UUID);
row.setFormVersion(4);
row.setAnswerUuid(ANSWER_UUID);
row.setAnswerRevision(revision);
row.setIsDeleted(0);
row.setContentHash(chars('d'));
row.setOccurredAt(LocalDateTime.now());
row.setSnapshotJson("{}");
row.setState(state);
row.setNextRetryAt(LocalDateTime.now().minusMinutes(1));
return row;
}
/** 被测服务:事务模板真跑回调(否则状态回写的 lambda 根本不执行),MQ 侧默认无 template */
private AnswerDeliveryService service(AnswerDeliveryRepository rows, AnswerRepository answers,
FormConsumerAccessService access, AnswerSnapshotService snapshots) {
@SuppressWarnings("unchecked")
ObjectProvider<RabbitTemplate> templates = mock(ObjectProvider.class);
when(templates.getIfAvailable()).thenReturn(null);
AnswerDeliveryService service = new AnswerDeliveryService(rows, answers,
snapshots == null ? mock(AnswerSnapshotService.class) : snapshots, access, mapper,
runningTransactionTemplate(), templates);
ReflectionTestUtils.setField(service, "sourceAppKey", SOURCE_APP_KEY);
ReflectionTestUtils.setField(service, "batchSize", 50);
ReflectionTestUtils.setField(service, "maxRetries", 8);
ReflectionTestUtils.setField(service, "backoffBaseSeconds", 30L);
ReflectionTestUtils.setField(service, "backoffCapSeconds", 3600L);
return service;
}
/** 模拟"已配 broker 且开了 correlated confirm"(容器里仍无 template,走发送异常路径) */
private static void withBroker(AnswerDeliveryService service) {
ReflectionTestUtils.setField(service, "host", "localhost");
ReflectionTestUtils.setField(service, "confirmType", "correlated");
}
private static TransactionTemplate runningTransactionTemplate() {
TransactionTemplate tx = mock(TransactionTemplate.class);
when(tx.execute(any())).thenAnswer(call ->
((TransactionCallback<?>) call.getArgument(0)).doInTransaction(null));
doAnswer(call -> {
Consumer<org.springframework.transaction.TransactionStatus> action = call.getArgument(0);
action.accept(null);
return null;
}).when(tx).executeWithoutResult(any());
return tx;
}
private AnswerSnapshotService snapshotService(AnswerRepository answers,
SubmissionContextRepository contexts,
FormConsumerAccessService access) {
return new AnswerSnapshotService(mapper, answers, contexts, access);
}
private SubmissionContextService contextService(SubmissionContextRepository contexts,
FormConsumerAccessService access,
FormDefService defService,
AnswerSnapshotService snapshots) {
Environment environment = mock(Environment.class);
when(environment.getProperty("form-center.hosted-fill.embed-origin." + CONSUMER_APP_KEY))
.thenReturn("http://localhost:8087");
when(environment.getProperty("form-center.hosted-fill.callback-url." + CONSUMER_APP_KEY))
.thenReturn("http://svc-hall.local/api/apply/intents");
return new SubmissionContextService(contexts, mock(AnswerRepository.class), access, defService,
snapshots, environment);
}
private FormConsumerAccessService grants() {
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
when(access.listEnabledSubscriptions(FORM_UUID, SCH)).thenReturn(
Collections.singletonList(grant(CONSUMER_APP_KEY, SCH, 7)));
return access;
}
private Answer answer(Long revision, String valuesJson, String status) {
Answer answer = new Answer();
answer.setId(9001L);
answer.setUuid(ANSWER_UUID);
answer.setFormUuid(FORM_UUID);
answer.setSchId(SCH);
answer.setAppCode("demo");
answer.setCode("honor_apply");
answer.setVersion(4);
answer.setSubmitterId(1001);
answer.setSubmitterSchId(SCH);
answer.setSubjectId(1001);
answer.setStatus(status);
answer.setValuesJson(valuesJson);
answer.setIdempotencyKey("idem-1");
answer.setAnswerRevision(revision);
answer.setIsDeleted(0);
answer.setCreateTime(LocalDateTime.of(2026, 10, 21, 10, 0, 0));
answer.setUpdateTime(LocalDateTime.of(2026, 10, 21, 10, 5, 0));
return answer;
}
private SubmissionContext context(LocalDateTime expiresAt) {
SubmissionContext ctx = new SubmissionContext();
ctx.setConsumerAppKey(CONSUMER_APP_KEY);
ctx.setConsumerSchoolId(SCH);
ctx.setIntentId(INTENT_ID);
ctx.setFormUuid(FORM_UUID);
ctx.setFormVersion(4);
ctx.setAppCode("demo");
ctx.setCode("honor_apply");
ctx.setSubmitterId(1001);
ctx.setTokenDigest(sha256HexOf("seed-token"));
ctx.setEmbedOrigin("http://localhost:8087");
ctx.setCallbackUrl("http://svc-hall.local/api/apply/intents");
ctx.setExpiresAt(expiresAt);
return ctx;
}
private ContextIssueRequest issueRequest() {
ContextIssueRequest req = new ContextIssueRequest();
req.setIntentId(INTENT_ID);
req.setConsumerSchoolId(SCH);
req.setFormUuid(FORM_UUID);
req.setFormVersion(4);
req.setSubmitterId(1001);
return req;
}
private Form publishedForm() {
Form form = new Form();
form.setUuid(FORM_UUID);
form.setSchId(SCH);
form.setAppCode("demo");
form.setCode("honor_apply");
form.setName("荣誉申请");
form.setCurrentVersion(4);
form.setStatus(FormEnums.FormStatus.PUBLISHED.getCode());
form.setAudience("STUDENT");
form.setIsDeleted(0);
form.setFrozen(0);
form.setFillLimit(0);
form.setAnswerNoEnabled(0);
return form;
}
private FormVersion versionRow(int version) {
FormVersion row = new FormVersion();
row.setFormId(1L);
row.setSchId(SCH);
row.setAppCode("demo");
row.setCode("honor_apply");
row.setVersion(version);
row.setSchemaJson("{\"groups\":[]}");
return row;
}
private static FormDefService.Published pinned(Form form, FormVersion row) {
return new FormDefService.Published(form, row);
}
private static BoaUser sessionUser(int id, int schId) {
BoaUser user = new BoaUser();
user.setId(id);
BoaRoleInfo current = new BoaRoleInfo();
current.setSchId(schId);
user.setCurrentRole(current);
return user;
}
private List<AnswerDelivery> captureDeliveries(AnswerDeliveryRepository rows) {
ArgumentCaptor<AnswerDelivery> captor = ArgumentCaptor.forClass(AnswerDelivery.class);
verify(rows, atLeastOnce()).saveAndFlush(captor.capture());
return captor.getAllValues();
}
private SubmissionContext captureContext(SubmissionContextRepository contexts) {
ArgumentCaptor<SubmissionContext> captor = ArgumentCaptor.forClass(SubmissionContext.class);
verify(contexts, atLeastOnce()).saveAndFlush(captor.capture());
return captor.getValue();
}
/** Java 8:String.repeat 不可用(本仓 target 1.8) */
private static String chars(char c) {
StringBuilder sb = new StringBuilder(64);
for (int i = 0; i < 64; i++) {
sb.append(c);
}
return sb.toString();
}
/** 测试侧独立实现(不复用被测私有方法):验证哈希输入字节与编码形态 */
private static String sha256HexOf(String input) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,490 @@
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.CatalogPage;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
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.AnswerRepository;
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.appauth.AppSecretProvider;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 消费应用授权与目录(v3,TASK_1)的规则单测。
*
* <p>断言的是<b>授权规则本身</b>,不打真实库(同 {@code FormAdminGuardTest} 的取向:把两表喂成桩,
* 规则改了立刻红)。IT 侧({@code OapiFormServiceIT})证的是载荷形状,覆盖不到"没授权的人看见什么",
* 而这一批恰恰是以拒绝为交付物的。六条判据逐条对应任务书:</p>
* <ol>
* <li>身份只取验签后的 appKey:无签名、注册表查不到、以及"自报 appCode"三种都要拒;</li>
* <li>owner / read / subscribe 是三件事:owner 能读但不能订阅,订阅必须有显式生效 grant;</li>
* <li>跨校与缺归属与不存在<b>同一句拒绝</b>(对外不可区分);</li>
* <li>撤权后读取与订阅都失败,且 {@code grantVersion} 进位(已生成的任务不被改写,那是 TASK_3 的断言);</li>
* <li>平台级表单对某校授予后,那一校之外的消费域照样读不到(租户限定不是纸面上的);</li>
* <li>目录<b>先过滤再分页</b>:{@code total} 必须是过滤后的条数,页大小上限 100。</li>
* </ol>
*/
@DisplayName("消费应用授权与目录 - v3 规则单测(TASK_1)")
class FcDiscoveryAccessTest {
private static final String SIGNED_CALLER = "100018";
private static final Long SCH = 5L;
private static final Long OTHER_SCH = 6L;
private AppSecretProvider secretProvider;
private ConsumerPrincipalResolver resolver;
private FormRepository formRepository;
private FormOwnershipRepository ownershipRepository;
private FormConsumerGrantRepository grantRepository;
private FormConsumerAccessService accessService;
private OapiFormService oapiFormService;
/** 桩的"库":uuid → 行 */
private final Map<String, Form> forms = new HashMap<>();
private final Map<String, FormOwnership> ownerships = new HashMap<>();
private final List<FormConsumerGrant> grants = new ArrayList<>();
@BeforeEach
void setUp() {
secretProvider = mock(AppSecretProvider.class);
when(secretProvider.getSecret(SIGNED_CALLER)).thenReturn("svc-hall-dev-secret-100018");
resolver = new ConsumerPrincipalResolver(secretProvider);
formRepository = mock(FormRepository.class);
ownershipRepository = mock(FormOwnershipRepository.class);
grantRepository = mock(FormConsumerGrantRepository.class);
when(formRepository.findByUuid(any())).thenAnswer(call ->
Optional.ofNullable(forms.get(call.<String>getArgument(0))));
when(ownershipRepository.findByFormUuid(any())).thenAnswer(call ->
Optional.ofNullable(ownerships.get(call.<String>getArgument(0))));
when(ownershipRepository.findByFormUuidIn(anyCollection())).thenAnswer(call -> {
List<FormOwnership> hit = new ArrayList<>();
for (String uuid : (Collection<String>) call.getArgument(0)) {
FormOwnership o = ownerships.get(uuid);
if (o != null) {
hit.add(o);
}
}
return hit;
});
when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(any(), any(), any()))
.thenAnswer(call -> grants.stream().filter(g -> g.getFormUuid().equals(call.getArgument(0))
&& g.getConsumerSchoolId().equals(call.<Long>getArgument(1))
&& g.getConsumerAppKey().equals(call.getArgument(2))).findFirst());
when(grantRepository.findForConsumer(anyCollection(), any(), any())).thenAnswer(call -> {
Collection<String> uuids = call.getArgument(0);
Long schoolId = call.getArgument(1);
String appKey = call.getArgument(2);
List<FormConsumerGrant> hit = new ArrayList<>();
for (FormConsumerGrant g : grants) {
if (uuids.contains(g.getFormUuid()) && schoolId.equals(g.getConsumerSchoolId())
&& appKey.equals(g.getConsumerAppKey()) && Integer.valueOf(1).equals(g.getEnabled())) {
hit.add(g);
}
}
return hit;
});
when(grantRepository.findEnabledSubscriptions(any(), any())).thenAnswer(call -> {
List<FormConsumerGrant> hit = new ArrayList<>();
for (FormConsumerGrant g : grants) {
if (g.getFormUuid().equals(call.getArgument(0)) && g.getConsumerSchoolId().equals(call.getArgument(1))
&& Integer.valueOf(1).equals(g.getEnabled())
&& Integer.valueOf(1).equals(g.getSubscribeAllowed())) {
hit.add(g);
}
}
return hit;
});
when(grantRepository.save(any())).thenAnswer(call -> {
FormConsumerGrant g = call.getArgument(0);
grants.removeIf(old -> old.getFormUuid().equals(g.getFormUuid())
&& old.getConsumerSchoolId().equals(g.getConsumerSchoolId())
&& old.getConsumerAppKey().equals(g.getConsumerAppKey()));
grants.add(g);
return g;
});
when(ownershipRepository.save(any())).thenAnswer(call -> {
FormOwnership o = call.getArgument(0);
ownerships.put(o.getFormUuid(), o);
return o;
});
when(formRepository.findPublishedInDomain(anyCollection())).thenAnswer(call -> {
Collection<Long> schIds = call.getArgument(0);
List<Form> hit = new ArrayList<>();
for (Form f : forms.values()) {
if (schIds.contains(f.getSchId())) {
hit.add(f);
}
}
return hit;
});
accessService = new FormConsumerAccessService(ownershipRepository, grantRepository, formRepository);
oapiFormService = new OapiFormService(mock(FormDefService.class), mock(FormDictService.class),
mock(AnswerRepository.class), formRepository, accessService);
}
// ==================== ① 身份 ====================
@Test
@DisplayName("没有验签属性 / 自报身份:一律 UNAUTHORIZED,不进入任何判定")
void unsignedOrSelfDeclaredCallerIsRejected() {
assertThatThrownBy(() -> resolver.resolveSignedCaller(null))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少已验签的应用身份");
assertThatThrownBy(() -> resolver.resolveSignedCaller(" "))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少已验签的应用身份");
// 签名过但平台注册表查不到(=身份已注销/验签墙漂移)
assertThatThrownBy(() -> resolver.resolveSignedCaller("100999"))
.isInstanceOf(BizException.class)
.hasMessageContaining("不可用");
assertThat(resolver.isRegisteredAppKey("100999")).isFalse();
assertThat(resolver.isRegisteredAppKey(SIGNED_CALLER)).isTrue();
}
@Test
@DisplayName("appCode 同名的应用不自认领:库里 app_code=svc-hall 但调用方无授权 → 拒")
void appCodeIsFilterNotIdentity() {
givenForm(form("u_own", SCH, "svc-hall", "leave_form", "请假单", 3));
assertThatThrownBy(() -> accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_own"))
.isInstanceOf(BizException.class)
.hasMessageContaining("表单不存在:u_own");
}
// ==================== ②③ owner / read / subscribe 与统一拒绝 ====================
@Test
@DisplayName("owner 能读,但不能订阅:订阅必须有显式生效 grant")
void ownerReadsButCannotSubscribe() {
givenForm(form("u_own", SCH, "svc-hall", "leave_form", "请假单", 3));
givenOwner("u_own", SCH, SIGNED_CALLER);
accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_own");
assertThatThrownBy(() -> accessService.requireSubscribe(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_own"))
.isInstanceOf(BizException.class)
.hasMessageContaining("表单不存在:u_own");
}
@Test
@DisplayName("跨校与不存在同一句拒绝:不给「这张表存在」的差别信息")
void crossSchoolAndMissingAndDeniedAreIndistinguishable() {
givenForm(form("u_own", SCH, "svc-hall", "leave_form", "请假单", 3));
givenOwner("u_own", SCH, SIGNED_CALLER);
String denied = message(() -> accessService.requireRead(
new ConsumerPrincipal(SIGNED_CALLER), OTHER_SCH, "u_own"));
String missing = message(() -> accessService.requireRead(
new ConsumerPrincipal(SIGNED_CALLER), OTHER_SCH, "u_never_created"));
assertThat(denied).isEqualTo("表单不存在:u_own");
assertThat(missing).isEqualTo("表单不存在:u_never_created");
}
@Test
@DisplayName("grant 给读不给订阅:读通、订阅拒;反过来只给订阅则读拒")
void readAndSubscribeAreSeparateSwitches() {
givenForm(form("u_g", SCH, "svc-hall", "leave_form", "请假单", 3));
givenGrant("u_g", SCH, SIGNED_CALLER, true, false, true);
accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_g");
assertThatThrownBy(() -> accessService.requireSubscribe(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_g"))
.isInstanceOf(BizException.class);
givenForm(form("u_s", SCH, "svc-hall", "dorm_form", "住宿登记", 1));
givenGrant("u_s", SCH, SIGNED_CALLER, false, true, true);
assertThatThrownBy(() -> accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_s"))
.isInstanceOf(BizException.class);
SubscriptionGrant sub = accessService.requireSubscribe(
new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_s");
assertThat(sub.getFormUuid()).isEqualTo("u_s");
assertThat(sub.isSubscribeAllowed()).isTrue();
assertThat(sub.isReadAllowed()).isFalse();
assertThat(sub.getGrantVersion()).isEqualTo(1);
}
// ==================== ④ 撤权 ====================
@Test
@DisplayName("撤权后读与订阅都失败;重申是新纪元(grantVersion 再进位)")
void revokeKillsReadAndSubscribeAndBumpsVersion() {
givenForm(form("u_r", SCH, "svc-hall", "leave_form", "请假单", 3));
givenGrant("u_r", SCH, SIGNED_CALLER, true, true, true);
accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_r");
assertThat(accessService.requireSubscribe(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_r").getGrantVersion())
.isEqualTo(1);
RevokeRequest revoke = new RevokeRequest();
revoke.setFormUuid("u_r");
revoke.setConsumerSchoolId(SCH);
revoke.setConsumerAppKey(SIGNED_CALLER);
GrantView revoked = accessService.revoke(SCH, revoke, false);
assertThat(revoked.isEnabled()).isFalse();
assertThat(revoked.getGrantVersion()).isEqualTo(2);
assertThatThrownBy(() -> accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_r"))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> accessService.requireSubscribe(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_r"))
.isInstanceOf(BizException.class);
assertThat(accessService.listEnabledSubscriptions("u_r", SCH)).isEmpty();
GrantRequest regrant = new GrantRequest();
regrant.setFormUuid("u_r");
regrant.setConsumerSchoolId(SCH);
regrant.setConsumerAppKey(SIGNED_CALLER);
regrant.setReadAllowed(true);
GrantView reissued = accessService.grant(SCH, regrant, false);
assertThat(reissued.isEnabled()).isTrue();
assertThat(reissued.getGrantVersion()).isEqualTo(3);
accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_r");
}
// ==================== ⑤ 平台级表单的租户限定 ====================
@Test
@DisplayName("平台级表单授予某校后:只有那一校读得到,答卷行集仍按消费域限定")
void platformFormGrantIsTenantScoped() {
givenForm(form("u_p", Form.PLATFORM_SCH_ID, "form-center", "honor_form", "荣誉学生", 2));
givenGrant("u_p", SCH, SIGNED_CALLER, true, true, true);
accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_p");
assertThatThrownBy(() -> accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), OTHER_SCH, "u_p"))
.isInstanceOf(BizException.class)
.hasMessageContaining("表单不存在:u_p");
List<SubscriptionGrant> mine = accessService.listEnabledSubscriptions("u_p", SCH);
assertThat(mine).hasSize(1);
assertThat(mine.get(0).getConsumerSchoolId()).isEqualTo(SCH);
assertThat(accessService.listEnabledSubscriptions("u_p", OTHER_SCH)).isEmpty();
}
// ==================== ⑥ 目录 ====================
@Test
@DisplayName("目录先过滤再分页:total 是过滤后的条数,不是库内行数")
void catalogFiltersBeforePaging() {
// 8 张已发布表,只有 3 张对本消费方可读
for (int i = 1; i <= 8; i++) {
String uuid = "u" + i;
givenForm(form(uuid, SCH, "svc-hall", "leave_form_" + i, "请假单" + i, i));
}
givenOwner("u1", SCH, SIGNED_CALLER);
givenGrant("u2", SCH, SIGNED_CALLER, true, false, true);
givenGrant("u4", SCH, SIGNED_CALLER, true, false, true);
// 同校另一张表只给了"订阅"开关,不该出现在可选目录里
givenGrant("u5", SCH, SIGNED_CALLER, false, true, true);
CatalogPage first = oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, null, 1, 2);
assertThat(first.getTotal()).isEqualTo(3);
assertThat(first.getItems()).hasSize(2);
assertThat(first.getItems()).allSatisfy(item -> {
assertThat(item.getVersion()).isNotNull();
assertThat(item.getAudience()).containsExactly("STUDENT");
});
CatalogPage second = oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, null, 2, 2);
assertThat(second.getTotal()).isEqualTo(3);
assertThat(second.getItems()).hasSize(1);
// 第三页没有内容,但 total 仍是过滤后的三条
assertThat(oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, null, 3, 2).getItems())
.isEmpty();
}
@Test
@DisplayName("目录的 keyword/appCode 只筛资源;page 从 1 起、pageSize 上限 100")
void catalogFiltersAndBounds() {
givenForm(form("u1", SCH, "svc-hall", "leave_form", "请假单", 1));
givenForm(form("u2", SCH, "form-center", "honor_form", "荣誉学生名单", 2));
givenOwner("u1", SCH, SIGNED_CALLER);
givenOwner("u2", SCH, SIGNED_CALLER);
assertThat(oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, "form-center", null, null, null)
.getItems()).extracting("formUuid").containsExactly("u2");
assertThat(oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, "荣誉", 1, null)
.getItems()).extracting("formUuid").containsExactly("u2");
assertThat(oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, "HONOR", 1, null)
.getItems()).extracting("formUuid").containsExactly("u2");
assertThatThrownBy(() -> oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, null, 0, 20))
.isInstanceOf(BizException.class).hasMessageContaining("page 从 1 起算");
assertThatThrownBy(() -> oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, null, 1, 101))
.isInstanceOf(BizException.class).hasMessageContaining("pageSize 需在 1~100 之间");
assertThatThrownBy(() -> oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), null, null, null, 1, 20))
.isInstanceOf(BizException.class).hasMessageContaining("schId 必填");
}
@Test
@DisplayName("目录不收未授权的行;未授权时读取端点同样被拒")
void catalogExcludesUnreadableAndReadEndpointsAreGuarded() {
givenForm(form("u1", SCH, "svc-hall", "leave_form", "请假单", 1));
CatalogPage page = oapiFormService.catalog(new ConsumerPrincipal(SIGNED_CALLER), SCH, null, null, 1, 20);
assertThat(page.getTotal()).isZero();
assertThat(page.getItems()).isEmpty();
assertThatThrownBy(() -> accessService.requireRead(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u1"))
.isInstanceOf(BizException.class);
}
// ==================== 管理面规则 ====================
@Test
@DisplayName("owner 绑定:归属校按表单自己的 sch_id 落;平台级表单只允许超管换绑")
void ownerBindingRules() {
givenForm(form("u1", SCH, "svc-hall", "leave_form", "请假单", 1));
OwnershipView view = accessService.setOwner(SCH, "u1", SIGNED_CALLER, false);
assertThat(view.getFormSchoolId()).isEqualTo(SCH);
assertThat(view.getOwnerAppKey()).isEqualTo(SIGNED_CALLER);
assertThat(accessService.findOwnership(SCH, "u1").getOwnerAppKey()).isEqualTo(SIGNED_CALLER);
givenForm(form("u0", Form.PLATFORM_SCH_ID, "form-center", "honor_form", "荣誉学生", 1));
assertThatThrownBy(() -> accessService.setOwner(0L, "u0", SIGNED_CALLER, false))
.isInstanceOf(BizException.class)
.hasMessageContaining("仅超管可设置");
accessService.setOwner(0L, "u0", SIGNED_CALLER, true);
assertThat(accessService.findOwnership(0L, "u0")).isNotNull();
// 他校表单与不存在同口径(管理面也拿不到"存在但不是你的")
assertThatThrownBy(() -> accessService.setOwner(OTHER_SCH, "u1", SIGNED_CALLER, false))
.isInstanceOf(BizException.class)
.hasMessageContaining("表单不存在:u1");
}
@Test
@DisplayName("grant 的域规则:校级只能给本校授;消费域给 0 直接拒;两把开关都不开也拒")
void grantDomainRules() {
givenForm(form("u1", SCH, "svc-hall", "leave_form", "请假单", 1));
GrantRequest otherSchool = new GrantRequest();
otherSchool.setFormUuid("u1");
otherSchool.setConsumerSchoolId(OTHER_SCH);
otherSchool.setConsumerAppKey(SIGNED_CALLER);
otherSchool.setReadAllowed(true);
assertThatThrownBy(() -> accessService.grant(SCH, otherSchool, false))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能给本校授予消费授权");
// 超管代某校授权是允许的
assertThat(accessService.grant(SCH, otherSchool, true).getConsumerSchoolId()).isEqualTo(OTHER_SCH);
GrantRequest platformTenant = new GrantRequest();
platformTenant.setFormUuid("u1");
platformTenant.setConsumerSchoolId(0L);
platformTenant.setConsumerAppKey(SIGNED_CALLER);
platformTenant.setReadAllowed(true);
assertThatThrownBy(() -> accessService.grant(SCH, platformTenant, true))
.isInstanceOf(BizException.class)
.hasMessageContaining("≥1");
GrantRequest noSwitch = new GrantRequest();
noSwitch.setFormUuid("u1");
noSwitch.setConsumerSchoolId(SCH);
noSwitch.setConsumerAppKey(SIGNED_CALLER);
assertThatThrownBy(() -> accessService.grant(SCH, noSwitch, false))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少开一把");
}
@Test
@DisplayName("能力收窄也是新纪元:只关订阅会让 grantVersion 进位,重复提交同一组开关则不动")
void narrowingCapabilitiesBumpsVersion() {
givenForm(form("u_n", SCH, "svc-hall", "leave_form", "请假单", 1));
givenGrant("u_n", SCH, SIGNED_CALLER, true, true, true);
GrantRequest narrow = new GrantRequest();
narrow.setFormUuid("u_n");
narrow.setConsumerSchoolId(SCH);
narrow.setConsumerAppKey(SIGNED_CALLER);
narrow.setReadAllowed(true);
narrow.setSubscribeAllowed(false);
assertThat(accessService.grant(SCH, narrow, false).getGrantVersion()).isEqualTo(2);
assertThatThrownBy(() -> accessService.requireSubscribe(new ConsumerPrincipal(SIGNED_CALLER), SCH, "u_n"))
.isInstanceOf(BizException.class);
// 同一组开关再提一次:没有变化就不进位(否则版本号会被无意义的重复保存刷过去)
assertThat(accessService.grant(SCH, narrow, false).getGrantVersion()).isEqualTo(2);
}
// ==================== 桩数据 ====================
private static Form form(String uuid, Long schId, String appCode, String code, String name, int version) {
Form f = new Form();
f.setUuid(uuid);
f.setSchId(schId);
f.setAppCode(appCode);
f.setCode(code);
f.setName(name);
f.setCurrentVersion(version);
f.setAudience("STUDENT");
f.setIsDeleted(0);
f.setUpdateTime(LocalDateTime.of(2026, 10, 1, 0, 0, 0).plusDays(version));
return f;
}
private void givenForm(Form form) {
forms.put(form.getUuid(), form);
}
private void givenOwner(String formUuid, Long formSchoolId, String ownerAppKey) {
FormOwnership ownership = new FormOwnership();
ownership.setFormUuid(formUuid);
ownership.setFormSchoolId(formSchoolId);
ownership.setOwnerAppKey(ownerAppKey);
ownerships.put(formUuid, ownership);
}
private void givenGrant(String formUuid, Long consumerSchoolId, String consumerAppKey,
boolean read, boolean subscribe, boolean enabled) {
FormConsumerGrant grant = new FormConsumerGrant();
grant.setFormUuid(formUuid);
grant.setConsumerSchoolId(consumerSchoolId);
grant.setConsumerAppKey(consumerAppKey);
grant.setReadAllowed(read ? 1 : 0);
grant.setSubscribeAllowed(subscribe ? 1 : 0);
grant.setEnabled(enabled ? 1 : 0);
grant.setGrantVersion(1);
grants.add(grant);
}
/** 取拒绝文案(断言"对外同一句"用,不看错误码家族) */
private static String message(Runnable call) {
try {
call.run();
return "未拒绝";
} catch (BizException e) {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
return e.getMessage();
}
}
}
File diff suppressed because it is too large Load Diff