diff --git a/docs/answer-delivery-v2.md b/docs/answer-delivery-v2.md new file mode 100644 index 0000000..05dd74c --- /dev/null +++ b/docs/answer-delivery-v2.md @@ -0,0 +1,300 @@ +# 答卷投递 v2 与可信建件(answer-delivery-v2) + +任务书:`docs/tasks/TASK_2.md`(【任务 2】可信 FC 提交 + 完整答卷投影 + SH→FL 办件编排)。 +本文档是**契约与运维权威**:写清"v2 与 v1 差在哪、双方字段闭集、故障状态表、broker 隔离的实值来源、 +可信关联与恢复时序、撤权竞态的生效边界"。代码事实以本文为准,与代码分叉时**先改文档再改代码**。 + +依赖:TASK_1《consumer-access-v3.md》的 `FormConsumerAccessService`(owner/read/subscribe 与 +`grantVersion`)是唯一授权事实源;本契约不写第二套 ACL,只消费它。 + +--- + +## 1. 协议版本差额(先说清"新增了什么、旧的那条怎么算") + +| 维度 | v1(既有,冻结) | v2(本任务新增) | +|---|---|---| +| exchange | `fc.events.v1`(topic,广播) | `fc.answer.delivery.v2`(topic,按消费方私有 routing key) | +| 消息形状 | 十字段闭集、**不含值文档**(`FcEventPublisher.Envelope`) | `SnapshotEnvelope` 十五字段,**含完整 snapshot** | +| 版本键 | `version` = 钉住的 schema 版本 | `formVersion`(schema 版本)+ **`answerRevision`**(对外快照单调修订号) | +| 可靠性 | afterCommit 尽力发、失败仅 warn、"拉取即事实源" | **答卷同事务落 outbox**,afterCommit 只唤醒扫描;publisher confirm + mandatory return | +| 消费方判据 | 广播 key 用资源 `app_code` | routing key 用**显式 consumerAppKey + 租户域**:`fc.snapshot.v2..` | +| 授权 | 无(能连 broker 就能看到元数据) | 逐订阅授权 + 每条带 `grantVersion`,发布前复核 | +| 目标端点 | 无(消费方靠拉取) | 新增 `POST /oapi/form/submission-context`、`GET /oapi/form/submission-context/{intentId}/receipt`、`POST /oapi/form/answer-delivery/receipt`、`POST /oapi/form/answer-delivery/grants/verify` | + +**v1 一条都不改**:字段闭集、routing key 形态、失败静默的取舍全部保留(`FcEventPublisherTest` +的十字段断言仍然成立)。差异登记见 §9 待修清单第 3 条。 + +## 2. 快照修订号 `answerRevision` + +`answer` 新增 `answer_revision BIGINT NOT NULL DEFAULT 0`:创建=1,此后**每个会改变对外快照的路径** +在同事务内 +1,并与不可变 outbox 行一起落库。覆盖点(一处不漏): + +| 路径 | 位置 | 事件类型 | +|---|---|---| +| 提交(手工/代录/导入/hosted) | `AnswerService#doSubmit` | `created` | +| 本人编辑值 | `AnswerReadService#editBySubmitter` | `updated` | +| 管理员编辑值 | `AnswerAdminService#doEdit` | `updated` | +| **改业务状态(含批量)** | `AnswerAdminService#doChangeStatus` | `updated` | +| 软删(本人 + 管理面 + 批量) | `softDeleteBySubmitter` / `doSoftDelete` | `tombstoned` | + +并发控制是两道闸,缺一都可能让"同一 revision 对应两份内容": + +1. **行级 CAS**:`AnswerRepository#compareAndSetRevision(id, expected, next)`(JPQL UPDATE 自带行锁; + 必须在改动实体字段**之前**调用,否则自动 flush 会先把新号写进去、CAS 自我拒绝); +2. **outbox 唯一键** `uk_delivery_revision(answer_uuid, answer_revision, consumer_app_key, + consumer_school_id)`:CAS 没挡住的极端形态在这里撞键,整事务回滚。 + 撞键被翻译成 `BizException(CUSTOM)`,**故意不**透传 `DataIntegrityViolationException`—— + 提交链路外层会把 DIVE 当"幂等重放"返回原答卷,那等于把并发丢更新伪装成重放成功。 + +没上 JPA `@Version`:那会让存量的分离实体 `save()` 路径(并发 IT、批量导入)突然开始抛 +`OptimisticLockException`,属于跨批次行为变更,不在本任务授权范围内。 + +## 3. 规范化与哈希:`fc.canon.v1`(跨端契约,两端各一份实现) + +实现:form-center `AnswerCanonicalizer`、svc-hall `AnswerProjectionStore`(消费端复算)。 +规则闭集(要改语义就升算法号,不许原地改): + +1. 对象键按 **UTF-16 代码元字典序**(`String.compareTo`)升序递归输出——钉比较器而不是"码点序", + 两端都是 JVM,同一个比较器才逐字一致; +2. 数组顺序保留(多选/排序是业务语义); +3. 数字 `decimalValue().stripTrailingZeros().toPlainString()`(`100.0→100`、`1E+2→100`,无指数); +4. 字符串只转义引号、反斜杠与控制字符(`\b\f\n\r\t` 短转义,其余 C0 用反斜杠+u+00+两位大写十六进制), + 非 ASCII(含中文)原样输出,哈希取 UTF-8 字节; +5. **空值策略:显式 null 成员保留**(缺失键与 `"k":null` 是两种事实,合并会丢"字段被清空"这次变化); +6. 快照顶层 `intent` 成员**不参与哈希**(它只对单个 consumer 可见,留着就会出现"同 revision 两份 hash"); +7. 时间恒为 `yyyy-MM-dd'T'HH:mm:ss`(不用 `ISO_LOCAL_DATE_TIME`:整分时它会省掉秒段,跨端就比不了); +8. 投递时间、重试计数、消费端扩展列都是信封/本地列,不进快照。 + +### 跨端夹具(逐字比对,两端测试钉同一串) + +输入快照(十个成员):`schId=5, appCode="demo", code="honor_apply", submitterId=1001, subjectId=7, +status="processed", values={"a":{"z":1,"y":null},"b":[2,1],"n":100.0,"e":true}, +answerNo=null, createTime="2026-10-21T10:00:00", updateTime=null` + +规范串(`FcAnswerDeliveryTest.CANONICAL_VECTOR` = `ShAnswerFlowTest.CANONICAL_VECTOR`): + +```json +{"answerNo":null,"appCode":"demo","code":"honor_apply","createTime":"2026-10-21T10:00:00","schId":5,"status":"processed","subjectId":7,"submitterId":1001,"updateTime":null,"values":{"a":{"y":null,"z":1},"b":[2,1],"e":true,"n":100}} +``` + +`contentHash` = 上述字节串的 SHA-256 小写十六进制(64 位)。 +消费端复算不一致 ⇒ 毒载荷拒绝;与**已存投影行**不一致 ⇒ 冲突待核验不覆盖(§5)。 + +### 信封示例(闭集十五字段;`intent` 只在 `snapshot` 内、只对目标 consumer 出现) + +```json +{"protocolVersion":"fc.answer.snapshot.v2","eventId":"5f0a…","eventType":"updated", + "sourceAppKey":"100021","consumerAppKey":"100018","consumerSchoolId":5,"grantVersion":7, + "formUuid":"f0e1…","formVersion":4,"answerUuid":"a1b2…","answerRevision":3,"isDeleted":false, + "contentHash":"<64hex>","occurredAt":"2026-10-21T10:05:00+08:00", + "snapshot":{"schId":5,"appCode":"demo","code":"honor_apply","submitterId":1001,"subjectId":1001, + "status":"processed","values":{},"answerNo":null,"createTime":"…","updateTime":"…", + "intent":{"intentId":"svchall-0123…","consumerAppKey":"100018","consumerSchoolId":5, + "submitterId":1001,"formVersion":4,"linkedRevision":1}}} +``` + +不进快照的:`anon_ip`/`anon_ua`(审计数据,契约 v2 C8/C12)、库内 `id`(契约 §1)、附件字节 +(值里只有 `{id,fileName,key}`,P34)。**无订阅的简单表单不产生任何投递行**,答卷照常提交。 + +## 4. 可信提交上下文(A.1~A.4) + +``` +SH POST /api/apply/intents(body 只有 siteCode/itemCode/clientKey) + └ 会话取人与校 → 事项入口判定:已发布 ∧ applyUrl 为空 ∧ onlineFlag=1 + ∧ TASK_1 成对绑定(fcFormUuid+fcFormVersion) ∧ flowGuid 就绪 ← 任一不过:不建件、不调流程 + └ 落 fc_apply_intent(uk: 校/人/事项/clientKey) + └ 签名 POST /oapi/form/submission-context(intentId=svchall-<自然键哈希>) + FC:resolveSignedCaller → requireRead + requireSubscribe + → 按 (formUuid,formVersion) 精确取版本行 + 表单当前开放填报 + → embedOrigin / callbackUrl 取服务端配置(缺键即拒,浏览器 URL 一律不采信) + → 同 (appKey,intentId) 回到同一行;令牌在该行上轮换(旧令牌即失效) + → 存 SHA-256 摘要 + 过期 + 绑定坐标(默认 30 分钟) + └ 返回 fillPageUrl = {FC 前端基址}/f/{code}?mode=embed&appCode=…&ctx= + +被嵌页(form-center front Fill.vue,hosted 通道) + └ GET /api/form/hosted/schema?contextToken → 按令牌钉的那一版取 schema(绝不回落当前发布版) + └ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken) + FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域, + 错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1) + → 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 同事务写 outbox + → 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝 + └ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin** + (不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用) + +SH POST /api/apply/intents/{id}/confirm("可确认"信号只触发这一步) + └ 签名 GET /oapi/form/submission-context/{intentId}/receipt + FC:只向所属消费应用返回 + 读取时再核一次 requireRead + → PENDING(还没答卷,不建件) / SUBMITTED(坐标+提交者+revision+hash+快照) + └ 逐字复核 consumerAppKey / 学校 / 提交者 / formUuid+formVersion → 建件(一次) +``` + +**`bizId`/`answerId` 在浏览器可提交的形状里根本没有字段位**——"不能用浏览器传入 bizId 直接建件" +是结构事实,不是审查约定。匿名与普通提交仍是中心自用的两条既有通道;hosted 首批只允许登录本人, +匿名答卷的 `submitter_id=0` 与上下文断言的真人对不上,"匿名完成消息用于本人办件"这条路径 +在 redeem 处就被结构性挡住。 + +## 5. 投影、inbox 与顺序无关合并(A.5/B.5) + +三张表各管一件事,**没有第五张"双轨表"**: + +| 表 | 唯一键 | 管什么 | +|---|---|---| +| `fc_apply_intent` | `(sch,submitter,item,clientKey)`、`(consumerAppKey,fcIntentId)`、`answer_uuid`、`apply_id` | 业务意图;"一次意图一个办件"的库级形态 | +| `fc_answer_projection` | `(answer_uuid, sch_id)`、`intent_id` | 答卷在本校的唯一投影 + **意图↔答卷↔办件的独立关联表**(保存 formUuid/formVersion/answerRevision/flowGuid) | +| `fc_answer_inbox` | `(event_id, consumer_app_key)` | 每封事件怎么处置的台账(APPLIED/IGNORED/CONFLICT/DEFERRED/REJECTED)+ 回执上报状态 | + +规则(`AnswerProjectionStore#applySnapshot`,一个事务内完成,DB 提交成功才 manual ack): + +- 更高 revision 才应用;更低忽略;同 revision 同 hash 幂等;同 revision 异 hash ⇒ `conflict_flag=1` + 留证**不覆盖**(投影与台账同时记,等人工/对账核验); +- 墓碑只是"更高 revision 的一种取值",本地保留 revision;旧 `created` 复活不了它; +- `biz_ext_json` 是业务端扩展列,中心快照永不写; +- 每条事件**不回查答案值文档**(全文就在消息里);应用结果经 §4 的成对签名端点回执, + FC 按 `eventId + 消费方 + revision` 逐字复核并幂等记录; +- **没有已验证意图就不建件**:事件先到 ⇒ 只落 `UNLINKED` 投影;确认先到 ⇒ 意图行落 + `ANSWERED/APPLIED`;后到的一方撞进同一组唯一键,两条路径收敛到同一个 intent/answer, + 不会各建一个办件。 + +## 6. broker 隔离配置(实值来源,缺一条就别开 `RABBITMQ_HOST`) + +v2 快照带完整值文档,"能连上 broker 的任何人可见"是不可接受的。以下每一项都是**部署契约的 +待填实值**,不是建议: + +| 项 | 实值来源 | 必须为真的判据 | +|---|---|---| +| vhost | projman 注入 `RABBITMQ_VHOST`(form-center 与 svc-hall 同一 vhost,与其他租户隔离) | 该 vhost 内不存在任何非授权消费者可绑的队列;跨应用共享 vhost 上**不得**发明文快照 | +| 发布方凭据 | `RABBITMQ_USER/PASS`(form-center 侧) | 账号权限:仅对 `fc.answer.delivery.v2` 有 `write`,对 `configure` 只覆盖自身 exchange 声明 | +| 消费方凭据 | svc-hall 侧同名 env | 只能 `configure`/`write`/`read` 自己前缀的队列与 binding:`svc-hall.fc.answer.snapshot.v2*`、`fc.snapshot.v2.<自身 appKey>.*`;**禁止** `#` 或他人前缀的通配绑定 | +| 队列与死信 | `FcAnswerMqConfiguration` 声明 | `svc-hall.fc.answer.snapshot.v2`(durable)+ `.dlq`(有界重试 3 次后不 requeue 落此,可查) | +| confirm/return | form-center `application.yml`:`publisher-confirm-type=correlated`、`publisher-returns=true`、`template.mandatory=true` | 拿不到逐条 confirm 就**不发送**(宁可积压 PENDING,也不按未确认投递) | +| 隔离能力确认 | 运维在交付单上逐项签字 | 上述任一项不成立时,v2 全量快照**不得**启用;此时只保留"签名收据"这条建件路径(本机联调即该形态) | + +## 7. 故障状态表(两侧 + 恢复;按码处置,不靠文案) + +### 7.1 form-center 投递行 `answer_delivery.state` + +| 状态 | 含义 | 进入条件 | 离开 | +|---|---|---|---| +| `PENDING` | 已落库待发 | 答卷事务内入队;或未配 broker 时长期停留 | 扫描取出→发送 | +| `BROKER_ACCEPTED` | **总线收下** | confirm ack=true | 终态(业务侧结论看 `consumer_applied_at`) | +| `FAILED` | 发送异常/不可路由/broker nack | AmqpException、mandatory return、ack=false | 退避 `20s×2^n`(base/cap 可配)后重扫 | +| `DEAD` | 有界重试耗尽 | retry_count ≥ max-retries(8) | 人工/`failedPage` 处置 | +| `GRANT_REVOKED` | 发布前 grant 复核未过 | 订阅撤销或 `grantVersion` 进位 | 终态,**不重放旧 grant** | + +**两个时刻分列**:`broker_accepted_at`(只由 confirm 回调写)与 `consumer_applied_at` + +`consumer_outcome`(只由消费方签名回执写)。broker 的 ack 永远不构成业务成功证据; +回执自称 `APPLIED` 但 hash 与信封不符 ⇒ 记 `CONFLICT`;同 eventId 重复上报不同结论 ⇒ 保留首次。 + +### 7.2 svc-hall inbox `state` / `report_state` + +`APPLIED` / `IGNORED`(低 revision 或同版同 hash 重放)/ `CONFLICT`(同版异 hash,不覆盖)/ +`DEFERRED`(授权无法确认,暂缓写入)/ `REJECTED`(闭集或哈希不过,毒载荷 ack 后留档); +`report_state`=`PENDING/SENT/FAILED` 由重报扫描追平(本地已落库但中心没收到确认时, +只补确认,不回滚事实)。 + +### 7.3 svc-hall `flow_apply_command.state` + +| 状态 | 进入 | 处置 | +|---|---|---| +| `PENDING` | 与办件同短事务落库 | 事务外发送(HTTP 不占长事务) | +| `UNKNOWN` | **任何**发送异常(超时/断连/4xx/5xx 一律算未知) | 先 `GET /oapi/flow/instance` 按业务键查 | +| `DONE` | 发起成功,或查得远端 10/20/30/40 并回填真实状态 | 终态 | +| `FAILED` | 退避中(含"流程中心未接入",等接入后自愈) | `next_retry_at` 到点重扫 | +| `DEAD` | 重试预算耗尽(默认 6 次) | 人工处置;不无限重投 | + +关键取舍:**不靠异常类型猜"远端有没有受理"**。查到 30/40 只回填镜像状态、**绝不重发 apply** +(重发会命中中心 R4 重启,把已拒绝/已撤回的件重新跑起来);只有查无才用同一 `requestToken` 补发。 + +### 7.4 flow-center 发起守卫分支(`FlowApplyRetryModels.ApplyResultV2.branch`,稳定码) + +| 码 | 判据 | 是否重启流程 | +|---|---|---| +| `CREATED` | 无既有实例 | 新建 + START 推进 | +| `IDEMPOTENT` | 命中 10/20 | 否(既有语义逐字不变) | +| `SAME_REQUEST` | `requestToken` 与行上相同 | **否**——同一次命令的重投 | +| `STALE_ROUND` | 请求 `attempt` ≤ 行 `apply_round`(NULL 按 1) | 否 | +| `REPLAY_ONLY` | `retryOnly=true` 命中既有实例(含 30/40) | 否 | +| `RESTARTED` | 更高 `attempt` + 新 token 命中 30/40 | 是,走既有 R4 | +| `CONCURRENT_CREATED` | 唯一键撞键后事务边界外重读命中 | 否(且先核归属) | + +归属复核三条:行 `apply_app_key`、`submitter_id`、流程线 `flowGuid` 与请求**任一明确不符即拒**, +不把别的应用相同 `bizType+bizId` 的办件当自己的结果。存量行(token/round 为 NULL)的守卫**缺省是关**。 + +### 7.5 时序 + +``` +提交点(答卷事务)→ revision+1 → outbox 行 → [提交] → afterCommit 唤醒扫描 → 发送(persistent) + ├ broker ack → broker_accepted_at (≠ 业务成功) + ├ broker nack → FAILED + 退避 + └ 不可路由/异常 → FAILED + 退避 →(耗尽)DEAD +消费方:manual ack 只在 DB 提交后;签名回执 → consumer_applied_at / consumer_outcome +进程崩溃窗口:outbox / flow_apply_command 都是库内行 → 重启后扫描续跑(不依赖内存闭包) +``` + +## 8. TASK_3 必须一次交付的扩展点(签名即契约,后续只调用不编辑) + +form-center:`AnswerSnapshotService.currentAuthorized(ConsumerPrincipal, Long consumerSchoolId, String answerUuid)`、 +`AnswerDeliveryService.replayAuthorized(ConsumerPrincipal, String answerUuid, Long expectedRevision, String operationId)` +(稳定码 `QUEUED/ALREADY_DELIVERED/GRANT_REVOKED/REVISION_MISMATCH/NO_DELIVERY_ROW`, +`operationId` 幂等吸收)、`AnswerDeliveryService.failedPage(ConsumerPrincipal, Long, Integer, Integer)`。 +svc-hall:普通消费保留 `AnswerProjectionStore.applySnapshot(SnapshotEnvelope)`;对账修复与分页改用下方§8.1明确的 +`repairAuthoritative(...)`、`manifestPage(...)`(只出轻量摘要与本地创建时间,不拉值文档)、 +`retireAbsent(AuthoritativeAbsenceProof)`(只做退役标记,不物理删、不改 revision; +`expectedRevision` 与本地不符时**拒绝退役**并留证)。 +快照适配与对账共用 §3 的 canonicalization/hash 契约,两端不得各写一套。 + +### 8.1 Task2 前置修正:对账与普通消费分离(2026-10-21) + +- 普通 `applySnapshot` 继续按MQ规则处理,同revision异hash不覆盖,且可能关联意图并建件;**TASK_3不得使用它执行修复**。 +- 新 `RepairResult repairAuthoritative(SnapshotEnvelope envelope, Long expectedRevision, String expectedHash, Integer expectedIsDeleted, String proofId)` 是服务端内部入口。TASK_3须先经平台签名的FC任务端点读取当前快照并核验task/consumer/school/form/answer/version/grant;proofId仅为审计引用,不是签名或浏览器可提交的授权凭据。使用现有fc.canon.v1复算hash,并绕过MQ授权缓存重新核验订阅且grantVersion必须相等。 +- expectedRevision/hash/isDeleted是本地读取的完整三元组;全部null仅表示观察到缺失。已有行用单条数据库条件更新检查三元组与school/form/answer仍匹配,更新仅限快照列与修复留证,保留bizExt/intent/apply/flow;缺失行插入UNLINKED投影。修复不调用建件、不写inbox或消费回执、不改意图。唯一键竞争须回滚并重新读取。 +- SH投影新增本地 `projection_version` 乐观锁列(与FC revision无关);权威条件更新同时递增该列,已读取旧实体的MQ/收据写入将触发乐观锁冲突,不能把权威修复覆盖回旧值。冲突必须整笔回滚,在事务外重新读取后有界重试。已有TASK_2库先执行 `svc-hall/server/sql/add_fc_projection_version.sql`,新库直接使用更新后的 `fc_projection_and_apply_intent.sql`,两脚本不可重复套用;本次未执行数据库升级。 +- 权威修复信封eventId最多36字符,可用任务/答卷操作的UUID;proofId最多128字符。新鲜授权响应必须同时匹配form/consumer/school及相等grantVersion,不能用别校/别应用响应或未来纪元放行。 +- 稳定返回:`APPLIED`、`LOCAL_CHANGED`(期望已变化,重新读取后有界重试)、`LOCAL_AHEAD`(本地revision更高,向FC复核且不降级)、`DEFERRED`(新鲜授权核验不通过,不写入)。同revision异hash仅在此权威入口可修复,普通MQ规则不变。 +- `ManifestPage manifestPage(Long schId, String formUuid, String cursor, Integer size)` 使用answerUuid升序keyset,初始cursor=null,按返回nextCursor继续;size默认200/上限500,多读一条确定completed。返回 `{rows,nextCursor,completed}`;rows为answerUuid/revision/contentHash/isDeleted/schId/createTime,结束时completed=true且nextCursor=null,不暴露库内id。 +- 本地分页不提供一致性快照证明。TASK_3须保存扫描结果/检查点,并用完整FC物化manifest、新鲜缺席证明及任务起点校验退役候选;本地createTime为空或晚于任务起点时不可退役。分页中途新增/变动不得被“没看到”解释为中心不存在。 +- 新增规则门禁 `ShReconcileExtensionTest`;MySQL实际条件更新/唯一键并发、签名任务端点及Rabbit运行期行为仍需联调验证,规则单测不替代它们。 + +## 9. 待修清单(本轮刻意不修;跨校那条必须在本任务联调验收前解决) + +1. **跨校授权缺"调用方属于该租户域"的事实源(TASK_1 遗留,优先级最高)**。 + `FormConsumerAccessService` 判的是"(formUuid, consumerSchoolId, consumerAppKey) 三元键上有没有生效 + grant",而 `consumerSchoolId` 一直是**调用方申报**的:grant 创建侧只限制"校管理员只能授给本校", + 超管可代任意校授,于是"这个 appKey 到底能不能在 school=6 下消费数据"没有第二重证明。 + 本任务的可信建件把这条链放大成写侧风险——SH 申报的是事项所在校,中心照单核。 + 修向(择一,需授权):FC 侧建立 appKey↔可服务学校集合的事实源(UserBase 注册信息), + 或把 grant 创建限定为"消费方应用注册校"。**本轮按边界要求不改 TASK_1 的 ACL/目录文件**。 +2. **托管填报的资格口径两侧不同**:SH 入口判"事项发布 + 在线办理 + FC 绑定",FC 仍按表单 + `audience` 门判"这个会话身份能不能填这张表"。若中心侧表单人群配得比事项服务对象严 + (例如只含教师),学生会话会出现"能打开被嵌页、提交被 403"。修向:要么 SH 入口镜像 audience, + 要么 hosted 通道把资格判据换成"意图授权"。本轮按任务书口径**保留 audience 门不动** + (它同时是 FC 自用通道的判据,改它要单独授权)。 +3. **v1 与 v2 的事件口径分叉**:改业务状态现在只产生 v2 的 `updated` 投递, + v1 `fc.events.v1` 仍不发状态变更事件(v1 的 `type` 闭集与消费方约定都在冻结范围外)。 + 需要显式决定"v1 冻结并声明"还是"v1 同步补发",否则仍依赖 v1 的旧消费方看不到 status 变化。 +4. **应急对账闸不认识新命令表**:`FlowReconcileJob` 仍只看 `service_apply.flow_status`, + 不知道 `flow_apply_command` 的 `UNKNOWN` 行(该文件构造器被基线测试逐位钉住,本轮不动)。 + 大面积事故时开闸对账与恢复扫描可能同时推进同一条链路,需要后续统一到一个处置出口。 +5. **回调"更高版本 ⇒ 新一轮"的判定还没与 attempt 映射对齐**(任务书 C.4 后半句,本轮未做): + `SvcFlowCallbackHandler#advance` 仍按 `instanceVersion > last_instance_version` 认定新轮, + 而本轮新增的持久轮次在 `flow_apply_command.attempt` 上,两侧没有交叉核对。 + 当前实际影响有限(R4 重启只由用户显式重提触发,且 `markResubmitted` 会清空旧轮结果), + 但"版本更高"在严格口径下**不自动证明**是新一轮——需要回调侧按 intent/attempt 映射复核后再放行。 + 本轮按"不顺手改既有接收器"处理,留待与 §9#4 一并在统一处置出口里做。 + +## 10. 未实现范围与证据边界(照实说) + +- **问卷类独立后端未定位**:本文 §3~§7 是可直接执行的消费协议与适配要求(信封、canonicalization、 + inbox/投影唯一键、回执、撤权竞态)。**未宣称"三端之外所有消费者已接入"**,也不虚构该消费端源码。 +- **部门实施冻结**:新办件首次发起只做既有规则的成员就绪检查;部门来源切换、未来节点 + 冻结/改派/挂起、当前科室停用行为细则**不在本任务**(按已知限制记录,不声称实现 D10 全部细则)。 +- 前端只做到**构建级验证**(`vite build` 两条绿),本机浏览器通道不可用 ⇒ 没有 iframe 视觉证据; + "消息来源三重校验(精确 origin + `event.source` + proto/v)"目前只有代码与协议级依据。 +- 测试是**规则单测**(FC 21 / SH 26 / FL 12 条,全绿)。桩事务能证"答卷写与 outbox 写在同一事务回调内、 + 回调抛出后两者都不落地、afterCommit 只唤醒扫描",**证不了 MySQL 的原子性**; + manual ack、死信路由、publisher confirm 的 broker 行为在本机无 rabbit 的情况下未跑通。 + 这两类都要在联调环境补做,才算 §6/§7 的完整验收。 +- DDL 三个文件(`form-center/service/sql/answer_delivery_v2.sql`、 + `svc-hall/server/sql/fc_projection_and_apply_intent.sql`、 + `flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql`) + 都是**非幂等**脚本,生产需人工执行;`ddl-auto=update` 的开发库会自动追加,两侧不能长期分叉。 diff --git a/docs/answer-reconcile-v1.md b/docs/answer-reconcile-v1.md new file mode 100644 index 0000000..5337bf3 --- /dev/null +++ b/docs/answer-reconcile-v1.md @@ -0,0 +1,364 @@ +# 答卷差异对账 v1(TASK_3 · FC 统一发起,SH 执行修复并回传可追踪结果) + +## 2026-10-22代码精修修订(优先于本文初版描述) + +- 指令签名方是FC,目标消费方是SH,两种appKey分开核验。SH必须设置 `svchall.answer-reconcile.source-app-key` 为FC实际签名身份;空值拒收。信封sourceAppKey也与该配置核对;不能要求FC使用SH身份签名。 +- 派发不可用同样先领取尝试次数,再进入有界退避/FAILED;不在QUEUED状态调用只匹配DISPATCHED的更新。 +- RUNNING心跳可多次刷新,绑定当前attempt;同attempt的RUNNING记录可以推进为终态结果。COMPLETED必须同时声明两侧扫描完成且中心manifest已物化完整。终态结果重复提交只作身份核验与幂等确认,不重新执行;终态任务不再提供清单/快照/缺席证明,返回TASK_TERMINAL。 +- SH同/旧attempt重复指令不重开终态行;更高attempt才可重新排队,work_version防止旧Worker回写新指令。执行/结果上报失败使用独立retry_count,默认6次、指数退避至上限;结果上报失败只重报,不重扫。新增本地字段升级脚本add_reconcile_work_recovery.sql未执行;新库使用answer_reconcile_work.sql,二者择一。 +- 修复冲突后的中心重读穿透缓存,缓存按页清理;DEFERRED保留计数/错误/候选但不推进水位。权威答复及信封逐项核对task/consumer/school/form/answer/version/grant,不把合法签名等同任意坐标合法。 +- 授权决策复用TASK_1的requireRead/requireSubscribe;资源校域校验已补齐,但应用可服务学校集合事实源仍未实现,不能宣称解决Task2 §9#1的全部问题。 +- 本轮仅编译/跳过测试打包;历史36+36测试结果不构成精修后状态机、数据库并发或签名互调验证。Task4已交付独立文档草案,不实施候选schema功能。 + +日期:2026-10-21。适用范围:`form-center`(中心/发起方)与 `svc-hall`(消费方/执行方)之间的答卷集合对账协议。 +前置:TASK_1 消费授权 v3(`docs/consumer-access-v3.md`)、TASK_2 快照投递 v2 及其前置修正 +(`docs/answer-delivery-v2.md` §8/§8.1、`docs/tasks/TASK_2_PREFIX_CHECK.md`)。 +本文是**协议与边界的事实源**;TASK_1/2 的文件一律只调用、不修改。 + +--- + +## 1. 这套东西解决什么、不解决什么 + +游标增量拉取(`GET /oapi/form/answers`)回答的是"最近变了哪些",它**不能**回答三个问题: +两侧集合到底一不一致、不一致时以谁为准、修复之后有没有可追踪的回报。本任务补的就是这三件事。 + +| 它是什么 | 它不是什么 | +|---|---| +| 一轮**双边集合验证** + 有界修复 + 可追踪回报 | 不是第二份增量拉取(一致的行不会去中心取值) | +| 中心签发的**任务级权威结论**(清单/当前快照/缺席证明) | 不是消费方本地判断的背书(本地永远不能自证"这行该删") | +| 投递链(TASK_2)之外的**独立修复通道** | 不是重投、不是建件、不是流程补偿 | +| `answer_reconcile_*` 三张中心表 + 一张消费表 | **不是** `answer_op_log`(不得拿它当任务/答卷/投递主表) | + +明确不在范围内:`svc-hall` 的 `FlowReconcileJob` 仍是流程恢复域,本任务不碰它; +对账修复不生成虚假的 MQ 消费成功回执,不重放历史 intent,不自动发起办件或流程命令。 + +--- + +## 2. 任务坐标:一行任务 = 一个「消费方 appKey + 消费租户域 + 表单」 + +`consumerSchoolId` 与投递行同口径,**就是答卷的归属校**(中心侧 `findByUuidAndSchId(uuid, consumerSchoolId)` +与 `listEnabledSubscriptions(formUuid, answer.getSchId())` 都是这个语义)。 + +任务范围只到**一张表单**,不做"一行任务覆盖全部表单"的设计,理由不是审美: + +1. 清单游标与消费端本地扫描游标都是 `answerUuid`(32 位小写十六进制,单字段升序)。 + 跨表单就必须复合游标,两侧无法锁步比较,多余行判定会失去依据; +2. **全量 = 按生效订阅裂变成多行任务**,每行仍然单表单。裂变性受 `max-tasks-per-run` 约束, + 因此不存在"一次人工触发把整库变成一个无界大任务"的形态; +3. 单并发、取消、租约、结果幂等这些语义都落在"一行任务"上才有清晰定义。 + +`scope=ANSWER`(单答卷)是同一坐标下的**一行清单**特例:清单里就这一行(或零行=缺席候选)。 + +### 2.1 简单表单不强建消费者 + +建任务的第一道闸是 TASK_1 的生效订阅(`enabled=1 AND subscribe_allowed=1 AND read_allowed=1`)。 +没有订阅就没有任务——本任务**不创建** grant 行,也不因为"这张表看起来有人要"而代填授权。 +读权与订阅权必须同时开着:清单答复与权威快照都走 `requireRead`,而消费端修复前的新鲜核验走 +`requireSubscribe`,缺一条就会造出一轮"能建、必失败"的任务。 + +`scope=ANSWER`(单答卷)**同样过这道闸**:答卷归属校就是它的消费租户域坐标,该域上没有生效订阅时 +直接以 `GRANT_REVOKED` 拒建——否则"指定一份答卷"会变成撤权后的后门。 + +--- + +## 3. 不可变清单(manifest) + +### 3.1 物化 + +`AnswerReconcileReadRepository`(本任务新增的只读仓库)按 `(form_uuid, sch_id)` 的 `uuid` 升序 keyset +分页读 `answer`,在**一个一致读事务内**把摘要写进 `answer_reconcile_manifest_row`: + +``` +(answer_uuid, revision, content_hash, is_deleted, form_uuid, form_version, seq_no) +``` + +- **含墓碑**:`is_deleted=1` 的行照样入清单。"缺席"与"存在但已删"是两个不同结论, + 后者必须走权威修复保留本地业务扩展,不能被当成前者; +- `content_hash` 由 `AnswerSnapshotService#contentHash` 现算(`fc.canon.v1`), + 与投递行同一条规范化实现。**本任务不复制任何规范化逻辑**; +- `form_version` 取该行 `answer.version`(答卷钉住的 schema 版本),不是表单当前版本; +- 清单行的 JPA 列全部 `updatable=false`,仓库层不提供 update/delete——它是这一轮的冻结事实源; +- 事务回滚时**清单行与检查点一起消失**,因此崩溃后是从头重物化,不存在"半截清单 + 半截检查点" + 这种会把差异判没的形态; +- 修订号为 0 的存量行原样入清单,但**不可被权威修复**(消费端 `validate` 要求 revision ≥ 1), + Worker 按 `DIFF_REVISION_ZERO` 只报告。 + +### 3.2 分页与完整性 + +``` +GET /oapi/form-sync/tasks/{taskId}/manifest?cursor={answerUuid}&size={n} +→ { taskId, rows[], nextCursor, completed, totalRows, totalPages, taskState } +``` + +- `size` 默认 `page-size`(200),上限 500;游标非 32 位十六进制即拒(页码与库内 id 都不是合法游标); +- **`completed=true` 是唯一的结束标记**。某一页空了不构成结束证据——中心清单物化完就有 + `total_rows/total_pages`,读侧只需把 `completed` 当判据; +- 清单未完整(`manifestComplete=0`)时该端点直接拒答分页,避免"读到空=集合空"的误判; +- 消费端把每页的 `taskState` 也当判据:中心已把任务落成 `CANCELLED` 时立刻停止剩余修复。 + +### 3.3 资源上限(超限=拒绝,不是硬扛) + +| 键 | 默认 | 越界处置 | +|---|---|---| +| `max-rows` | 20000 | 建任务前按 `count(answer)` 预检;越界→不落清单,任务 `FAILED` + `RESOURCE_LIMIT` + 原因 | +| `max-pages` | 200 | 双保险(页大小被调大时仍兜底);物化途中越界→整笔回滚 + `FAILED` | +| `max-tasks-per-run` | 20 | 一次调度/一次 ALL 人工触发最多裂变这么多行 | +| `max-error-items` / `max-error-chars` | 50 / 8000 | 结果明细截断存,计数不丢 | +| SH `max-pending-extras` | 500 | 多余行候选越界→**本轮一行都不退役**,只报告 | + +拒绝建任务会**留下一行 `FAILED` 任务**并带稳定码与原因:拒绝必须是可查的事实, +而不是操作人屏幕上闪过的一行字。这一行永远不会被派发(`manifest_state=FAILED`)。 + +--- + +## 4. 任务状态机 + +``` + 物化完成 派发 2xx 结果回报 +QUEUED ──────────────────────────► QUEUED ────────────► DISPATCHED ────────────┬─► COMPLETED + │ (manifest PENDING→COMPLETE) (lease 起算) ├─► PARTIAL + │ ├─► FAILED + └── 超限/撤权/跨校 → FAILED(带原因,永不派发) └─► CANCELLED +RUNNING ← 消费端进度回报(只续租,不落终态) +``` + +七个状态是本任务的验收闭集,语义钉死: + +| 状态 | 判据 | 明确不代表 | +|---|---|---| +| `QUEUED` | 清单已完整(或尚未物化),等派发 | — | +| `DISPATCHED` | 对端 2xx 收下指令并落库 | **不代表**任何修复;不代表消费方本地数据已对 | +| `RUNNING` | 消费端进度回报刷新过租约 | 不是终态,不推进计数结论 | +| `COMPLETED` | 清单与本地扫描都完整,且无未决差异 | 不代表"修好了很多行"(可能是本来就一致) | +| `PARTIAL` | 跑完了,但仍有只报告项/暂停项/重试耗尽项 | 不等于失败,也不等于成功 | +| `FAILED` | 资源拒绝、物化中断、派发或租约预算耗尽 | 不猜对端状态 | +| `CANCELLED` | 人工取消,或核验时发现撤权/授权纪元进位 | — | + +推进规则:**只有 result 回报能推终态**;`attempt` 是幂等键的一部分。 + +- 派发领取与租约续期都是**条件更新**(`claimForDispatch` 带 `state='QUEUED' AND manifest_complete=1 + AND (next_dispatch_at IS NULL OR <= now)`),两个扫描线程同时看到同一行时只有一个能刷新租约; +- 同一消费方存在 `DISPATCHED/RUNNING` 在途任务时**不派下一条**(单并发); +- 租约超期:`attempt < max-dispatch-attempts` → 回 `QUEUED` 并按 `base×2^n`(封顶 `backoff-cap-seconds`) + 重派,同 `taskId` 复用检查点;到上限 → `FAILED`,留可查记录,不无限重投。 +- 消费端收到**更大 attempt** 的指令时:本地行若已是终态或已停摆,就按新 attempt **重新排队并从水位续读** + (只回 `duplicate` 吸掉会让中心一路重派到预算耗尽,最后落成 `FAILED` 假象); + 若本地仍在跑且租约有效,则只刷新身份与坐标,**不打断正在执行的这一轮**。 + 更旧 attempt 一律不动 `attempt`、不动检查点、不动已提交结论。 + +--- + +## 5. 协议端点与身份纪律 + +| 方向 | 端点 | 用途 | +|---|---|---| +| FC | `GET /oapi/form-sync/tasks/{taskId}/manifest` | 读这一轮的不可变清单分页 | +| FC | `GET /oapi/form-sync/tasks/{taskId}/answers/{answerUuid}[?expectedRevision=]` | 权威当前快照信封,或权威缺席证明 | +| FC | `POST /oapi/form-sync/tasks/{taskId}/result` | 结果回报(`(taskId, attempt)` 幂等) | +| FC | `POST` 派发 → SH `POST /oapi/form-sync/reconcile` | 指令下发(服务端登记地址 + 平台签名) | +| FC 管理面 | `/api/form/admin/sync/**` | 建任务/列表/详情/结果/取消/单答卷重放/投递失败查看 | + +五条不可绕的纪律: + +1. **身份只来自平台验签**。中心读 `request.getAttribute(AppAuthFilter.APP_AUTH_ATTR)` 经 + `ConsumerPrincipalResolver` 换 `ConsumerPrincipal`;请求体里自报的 `consumerAppKey` 只用于 + **比对是否冒充**,与验签身份不符即 403。消费端同理:指令的 `consumerAppKey` 与验签来的 appKey + 必须都等于本应用消费身份,否则整条指令拒收。 +2. **每一次**清单/快照/结果调用都重新核验:任务存在 → 身份等于任务目标 → 未取消 → + 最新 grant 仍生效(`enabled`/`subscribe`/`read` 三条)且**纪元未进位**。 + 撤权或纪元进位时中心把任务落成 `CANCELLED` 并返回稳定码,消费端据此停止剩余投递与修复—— + 绝不因"建任务时有权"永久放行。 +3. **派发地址只能来自服务端登记表** `fc.answer.reconcile.targets.{consumerAppKey}`。 + 指令载荷里没有 callbackUrl 字段,浏览器传入的任何 URL 无从采信;未登记 → + `TARGET_NOT_REGISTERED`,任务退回 `QUEUED` 等人工处置,不改数据。 +4. 缺席证明与权威快照都由中心构造,`formUuid` 取任务行、`formVersion` 取中心**当前答卷行** + (不从旧清单猜版本),`eventId` 是服务端 UUID(≤36),`proofId` ≤128。 + `proofId` 只是可追踪引用,**不是身份凭证**;消费端严禁把 `repairAuthoritative` 直接绑到浏览器可提交的入口上。 +5. 敏感数据:答卷 `values` 只出现在签名任务端点按授权下发的快照里;错误明细与日志里 + 只有 `consumer / task / range / answer / revision`,逐字段搬运并截断,绝不整包序列化请求体。 + +--- + +## 6. 差异分类与修复规则(消费侧) + +Worker 每轮从**同一水位**读两侧一页(中心清单 + `AnswerProjectionStore#manifestPage`), +取两侧页末键的较小者作本轮边界,比较 `(answerUuid, revision, contentHash, isDeleted)`: + +| 方向 | 稳定码 | 处置 | +|---|---|---| +| 中心有、本地无 | `MISSING` | 取中心**当前**快照 → `repairAuthoritative(env, null, null, null, proofId)`(期望三元组全 null=观察到缺失) | +| 本地 revision 更低 | `STALE` | 取中心当前快照 → 按本地扫描三元组条件更新 | +| 同 revision 异 hash | `HASH_CONFLICT` | 先记冲突,再取中心当前值走**同一权威入口**;不走普通 `applySnapshot` 强盖 | +| `isDeleted` 不符 | `TOMBSTONE` | 同一权威修复原语;本地业务扩展列(`biz_ext_json`/`apply_id`/`flow_guid`/`link_state`)由条件更新排除在写集之外 | +| 本地 revision 更高 | `LOCAL_AHEAD` | 向中心复核**当前值**:中心更大→按 `STALE` 修到新值;否则只报告,**禁止降级覆盖** | +| 本地有、清单里没有 | `EXTRA_ROW` | 只登记候选,见 §7 | +| 修订号为 0 | `DIFF_REVISION_ZERO` | 只报告(契约要求 revision ≥ 1 才受理修复) | + +**一致的行不向中心取快照**:完整扫描不是第二份全量拉取,只有检出差异才发起一次权威读取。 + +### 6.1 RepairResult 四分支 + +| 返回值 | 含义 | Worker 处置 | +|---|---|---| +| `APPLIED` | 数据面已按中心当前值落定 | 计数 +1;**不代表**建件或流程成功 | +| `LOCAL_CHANGED` | 本地期望三元组已变化(含条件更新未命中) | 重读本地行与中心当前值后**有界重试** | +| `LOCAL_AHEAD` | 本地 revision 更高 | 停止该条,向中心复核,不降级 | +| `DEFERRED` | 新鲜订阅核验不可达/撤权/授权纪元不等 | 暂停修复,本轮按任务授权状态收尾(`PARTIAL`) | + +### 6.2 并发与回滚(本轮最关键的一条) + +- `projection_version` 乐观锁冲突、`uk_projection_answer` 并发插入的唯一键异常,都必须使 + **当笔事务整笔回滚**,然后在**事务外**重读后再有界重试(`max-repair-retries`,默认 3)。 + Worker 的执行循环本身**不开事务**:领取、检查点落库、单次修复各自是独立短事务, + 这样才可能在"已标 rollback-only 的事务"之外续跑。 +- 缺失插入撞唯一键后重读若已存在,则本轮该条从 `MISSING` 退化为按本地三元组比较,不硬插第二条。 +- **运行联调前必须确认 `svc-hall/server/sql/add_fc_projection_version.sql` 已应用**(已有库), + 或新库直接用含该列的建表脚本。TASK_3 不得绕开这一列。 + +### 6.3 禁止事项 + +对账修复**禁止调用 `applySnapshot`**。它仍是普通 MQ 消费入口,保持"同版异 hash 不覆盖"的既有规则, +并可能关联意图/建件/发流程。修复入口只更新中心快照列与修复留证(`last_event_id`/`conflict_detail`), +不碰 `inbox`/`intent`/`apply`/`flow`,不生成 MQ 消费成功回执。 + +--- + +## 7. 多余行与权威缺席证明 + +`retireAbsent(AuthoritativeAbsenceProof)` 的前置条件**全部**成立才允许调用: + +1. 中心清单整轮完整结束(`manifestComplete=1` 且本轮读到 `completed=true`); +2. 本地图像也扫完(`localCompleted=1`); +3. 该行属于本任务的 `(sch_id, form_uuid)`; +4. 该行 `createTime` **不晚于**任务起点 `windowStart` 且**非空**; + 晚于起点=期间新增,为空或归属不确定=不可判,一律只报告; +5. 中心对同一 `answerUuid` 答复 `presence=ABSENT` 且证明的 + `taskId / formUuid / schId / answerUuid / expectedRevision` 与本地候选逐字一致; +6. 多余行候选总数未越 `max-pending-extras`。 + +`expectedRevision` 由消费端扫描观察值提供,作用是与 `retireAbsent` 内部的"版本不符即不退役 + 标冲突" +形成并发护栏;**它不是中心对本地版本的断言**。 + +任何跨页失败、撤权、范围改变、候选超限的情况:未见到的行绝不当作"应删除", +已经登记为候选的行也**整体不退役**(宁缺勿滥),只把差异报回去。 + +--- + +## 8. 与 TASK_2 服务能力的接触面(只调用) + +| 能力 | 中心侧 | 消费侧 | +|---|---|---| +| 权威当前快照 | `AnswerSnapshotService#currentAuthorized(caller, consumerSchoolId, answerUuid)` | — | +| 修复入口 | 签发信封 | `AnswerProjectionStore#repairAuthoritative(env, expRev, expHash, expDel, proofId)` | +| 本地集合扫描 | — | `AnswerProjectionStore#manifestPage(schId, formUuid, cursor, size)` | +| 缺席处置 | 签发证明 | `AnswerProjectionStore#retireAbsent(proof)` | +| 投递重放 | `AnswerDeliveryService#replayAuthorized(...)`(管理面单答卷重放) | — | +| 投递失败查看 | `AnswerDeliveryService#failedPage(...)` | — | +| 授权判据 | `FormConsumerAccessService#requireRead/requireSubscribe/listEnabledSubscriptions`、`FormConsumerGrantRepository` | 修复前 `verifyGrants` 新鲜核验(TASK_2 内部) | + +任务书 §59 的停止线本次未触发:**没有**在 `OapiFormService` / `AnswerRepository` / +`FcEventPublisher` / `AnswerProjectionStore` 里补快捷查询。摘要取数走本任务新增的 +`AnswerReconcileReadRepository`;哈希复用中心既有实现,未复制规范化代码。 + +--- + +## 9. 配置键(默认值即交付形态,本任务不改两个应用的 `application.yml`) + +中心 `fc.answer.reconcile.*`(`AnswerReconcileConfiguration.AnswerReconcileProperties`): + +| 键 | 默认 | 说明 | +|---|---|---| +| `schedule-enabled` | **false** | 每日建任务总闸;关时一行任务都不建 | +| `schedule-cron` | `0 0 2 * * ?` | 拟配置时点 | +| `schedule-zone` | `Asia/Shanghai` | 营业日按此时区取 | +| `dispatch-enabled` | **false** | 物化 + 派发 + 租约回收总闸;关时仍可建任务、查结果、手动重放 | +| `page-size` / `max-rows` / `max-pages` | 200 / 20000 / 200 | 见 §3.3 | +| `max-tasks-per-run` | 20 | 裂变性上限 | +| `lease-seconds` | 900 | 租约 | +| `max-dispatch-attempts` | 4 | 含首派的派发预算 | +| `backoff-base-seconds` / `backoff-cap-seconds` | 60 / 3600 | 退避 | +| `targets.{appKey}` | 空 | **必填**才能派发:已登记的消费方后台服务地址(含 context-path) | +| `dispatch-path` | `/oapi/form-sync/reconcile` | 派发路径 | +| `source-app-key` | 空→回落 `yh.app-auth.app-key` | 信封 `sourceAppKey` | +| `scan-interval-ms` / `scan-initial-delay-ms` | 15000 / 45000 | 中心扫描闹钟 | + +消费侧 `svchall.answer-reconcile.*`: + +| 键 | 默认 | 说明 | +|---|---|---| +| `enabled` | **false** | 执行总闸(接收落库不受它管——落库只是事实,执行才动数据) | +| `lease-seconds` | 900 | 本地执行租约(超租约的 `RUNNING` 可被重新领取并从水位续读) | +| `max-repair-retries` | 3 | 单条差异的有界重试预算 | +| `max-pending-extras` | 500 | 多余行候选上限(越界=整轮不退役) | +| `max-error-items` | 50 | 明细上限 | +| `max-work-per-scan` | 1 | 每 tick 执行任务数(单并发) | +| `progress-report-rounds` | 10 | 每 N 轮向中心回报一次 `RUNNING` 以续租 | +| `backoff-base-seconds` / `backoff-cap-seconds` | 60 / 1800 | 中断/回报不可达的退避 | +| `scan-interval-ms` / `scan-initial-delay-ms` | 30000 / 20000 | 消费侧扫描闹钟 | + +### 9.1 部署顺序(硬约束) + +1. 中心:`sql/answer_reconcile_v1.sql`(三张表;已有库先确认表不存在,脚本非幂等); +2. 消费侧:确认 `fc_answer_projection.projection_version` 已存在 + (新库用 `fc_projection_and_apply_intent.sql`,已有库用 `add_fc_projection_version.sql`,二选一), + 再执行 `sql/answer_reconcile_work.sql`; +3. 密钥与白名单:消费方必须已登记进中心 `yh.app-auth.trusted-apps`(本机为 `100018`), + 中心 `100021` 必须已登记进消费方 `trusted-apps` —— 否则签名互验 401; +4. 服务目录/派发地址:中心侧配 `fc.answer.reconcile.targets.100018`, + 消费侧配 `YH_OPEN_SERVICES_FORMCENTER`(`yh.open.services.form-center` 不在本轮可改文件内,需运行时注入); +5. 先有生效 grant,再开 `schedule-enabled` / `dispatch-enabled`。 + **本轮没有开启任何调度**,也没有对真实数据做过派发或修复。 + +--- + +## 10. 验收证据与未验收项(诚实边界) + +已取证的(本机桩级,规则单测): + +- `FcReconcileTaskTest`:36 项 —— 调度唯一键幂等、未授权订阅不建任务、资源超限拒绝并留原因、 + 单并发不叠任务、跨校范围拒绝、裂变上限、清单物化含墓碑且哈希同源自 TASK_2、 + 物化越界整笔不落、UUID 游标跨页/整页到底/非法游标、清单未完整拒答分页、 + 信封坐标取中心已核验值(版本来自当前答卷行而非旧清单)、墓碑形态、 + 缺席证明三前置、期间新增不签证明、撤权/纪元进位即取消、跨消费方读取 403、 + 已取消任务不再答复、重复回报幂等吸收、旧 attempt 不倒退新 attempt、 + 假 result(超派发 attempt / 身份不符)拒、`COMPLETED` 与 `PARTIAL` 与 `RUNNING` 三种推进区别、 + 回报状态闭集外即拒、明细逐字段规整且有上限、派发 2xx 只标 `DISPATCHED`、 + 同消费方在途不派下一条、未登记地址不猜 URL、派发预算耗尽即 `FAILED`、 + 租约超期重派与耗尽落败、重放先重过 ACL 再执行、跨域寻址按不存在、取消幂等。 +- `ShReconcileTaskTest`:36 项 —— 接收与执行解耦(2xx 只落 `QUEUED`)、三类身份不符拒收、 + 坐标/起点/attempt 校验、重复指令复用 taskId 与检查点、开关未开或出站未就绪不动数据、 + 缺失/陈旧/同版错 hash/墓碑四类走权威入口且 `applySnapshot` 永不被调、 + 本地更高不降级(含中心已推进到新值时修到新值)、一致行完全不向中心取快照、 + 修订号 0 只报告、`DEFERRED` 即暂停、`LOCAL_AHEAD` 分支、`LOCAL_CHANGED` 事务外重读后有界重试、 + 乐观锁与唯一键竞争整笔回滚后重试且预算耗尽只报告、多余行需完整扫描 + 匹配证明才退役、 + 证明坐标不符/只报告口径不退役、期间新增与 `createTime` 为空永不退役、候选越界整轮不退役、 + UUID 游标锁步跨页、空页未到底即停滞且不回报假结果、跨页失败保检查点且不退役、 + 断线重启从水位续读并计 `resumed`、中心取消即停、中心不可达不写终态、 + 结果送不到时留 `QUEUED` 等重报、旧 attempt 回报被中心判陈旧时不覆盖结论、进度续租不落终态、 + 错误明细带全四坐标且不含 values、租约有效时新 attempt 只刷新身份不打断在跑的一轮。 + +门禁命令与退出码原文、首跑 7 红的根因表、以及门禁全绿后自查补的三处缺陷 +(`ANSWER` 范围漏过订阅核验、一致行也向中心取快照、终态行被重派时不重新排队)见 +`docs/tasks/evidence/task3/gate-commands.md`;Surefire 原文同目录。 + +**未取得、留给联调的证据**(不得被上述绿灯冒充): + +1. 真实 MySQL:一致读快照的实际隔离效果、`uk_manifest_row`/`uk_result_attempt`/`uk_reconcile_work_task` + 的真冲突、`projection_version` 乐观锁与 `uk_projection_answer` 并发插入的整笔回滚、 + 条件更新在并发下的实际原子性;两个 DDL 脚本本轮**未执行**; +2. 真实调度:本轮没有触发过 `@Scheduled`,02:00 时点与租约超期都是配置形态 + 桩级验证; +3. 真实签名互调:中心↔消费方的 `/oapi/**` 往返、密钥互登、服务目录解析都未在本机跑通 + (本机 `yh.open.services.form-center` 需运行时注入); +4. 前端视觉证据:`form-center/front` 只到构建级验证;`/admin/sync` 的**菜单模块行尚未在 UserBase 登记** + (本任务禁改 `UserBase/**`),当前只能以 hash 地址直达; +5. TASK_2 §9 的五条待修(尤其跨校授权缺"调用方属于该租户域"的事实源)与 TASK_1 的四条审计问题 + 各自保留。本任务的应对是**拒绝建跨校任务**(`SCOPE_MISMATCH`),不是绕过它。 + +--- + +## 11. 修订记录 + +| 日期 | 变更 | 依据 | +|---|---|---| +| 2026-10-21 | v1 落地:任务坐标到表单为止(全量=按订阅裂变)、清单不可变物化 + `completed` 唯一结束判据、双流锁步水位、缺席证明六前置、`RepairResult` 四分支与整笔回滚后事务外重读、派发 2xx 与终态严格分离、调度与派发双闸默认关 | 任务书 TASK_3 改动点 1–8 与 `TASK_2_PREFIX_CHECK.md` 接入清单 | +| 2026-10-21 | 明确不接触 `application.yml`:全部键以 `@ConfigurationProperties`/`@Value` 默认值交付,env 注入生效 | 约束「配置在本任务独立 `@Configuration` 中,不争用 TASK_2 配置」 | diff --git a/docs/consumer-access-v3.md b/docs/consumer-access-v3.md new file mode 100644 index 0000000..1572bfd --- /dev/null +++ b/docs/consumer-access-v3.md @@ -0,0 +1,117 @@ +# 表单中心 · 消费应用授权与表单目录(v3,TASK_1) + +对外契约的增补件,挂在《表单中心契约 v2》§9(对外读接口)之下。落地范围=**发现(目录)+ 授权(read/subscribe)+ 稳定引用(uuid+version)**, +不含部门选型、不含 Apply 输入换轨(TASK_2)、不含事件投递(TASK_3)。 + +依据:VERIFICATION V03/V04/V07;原骨架 T01 与 T05 的确切版本读取部分。 + +--- + +## §1 调用身份:只有一个来源 + +`/oapi/form/**` 的身份 = 平台 `AppAuthFilter` 验签通过后写在请求属性 `yh.app.auth` 上的 **appKey**。 + +- 三件套 `X-App-Key / X-Timestamp / X-Signature` 缺失 → 平台链 401(`enforce-patterns=[/oapi/*]`),进不到业务层; +- 属性拿到了但密钥不可用,或 `OpenApiAppClient.getAppByKey` 不能在UserBase确认同一appKey → `UNAUTHORIZED`; + trusted-apps只提供验签密钥,不充当注册事实;注册查询不可达也拒绝,不把降级放行写成可用性保证; +- 查询参数 `appCode` 与 `schId` **只是资源筛选与租户域**,不构成身份、不授予拥有权; +- `app_registry` 仍是快应用目录(四业务列、零角色),本批一行不加,也不因 slug 同名自动认领任何资源。 + +## §2 落型:资源拥有者与消费租户分开 + +| 表 | 键 | 语义 | +|---|---|---| +| `form_ownership` | `uk_ownership_form(form_uuid)` | 这张表单是哪个 appKey 的资产;`form_school_id` 存绑定时表单自己的 `sch_id`(0=平台级) | +| `form_consumer_grant` | `uk_grant(form_uuid, consumer_school_id, consumer_app_key)` | 允许"这个租户的这个应用"**读**和/或**订阅**这张表 | + +- **默认拒绝**:`read_allowed` / `subscribe_allowed` 默认 0,没有隐式全集; +- **读与订阅分权**:owner 天然能读自己那张表的 schema/答卷,**订阅必须另开一行 grant**(能看 ≠ 能往外推); +- **租户维度不可省**:只按 appKey 授权会丢掉"读答卷仍以该校行集限定"这条。平台级表单(`form.sch_id=0`)对某校授予后, + 那一校看到的答卷仍只有自己的行(行集由调用侧 `schId` 条件保证,授权不放宽它); +- **owner 由管理面写**:校管理员/超管在写入前核验 appKey 的 UserBase 注册身份(§1 同一张注册表,只判存在、不回显密钥), + 平台级表单的 owner 绑定**仅超管**可设——否则校管理员可以把平台资源认领给自己应用; +- **统一拒绝口径**:跨校 / 缺归属 / 不存在 / 未授权 → 同一句 `表单不存在:`,真实原因只进服务端 warn 日志; + `answer` 端点被拒时回答 `答卷不存在:`(答"表单不存在"等于同时确认两张对象都存在); +- **撤销不删行**:`enabled=0` + `grant_version` 进位;重申同样是新纪元(版本再进位)。 + +DDL:`sql/form_consumer_access_v3.sql`(无物理外键,`form_uuid` 为逻辑引用;两表零存量、无需回填)。 + +## §3 目录端点与既有端点的授权 + +``` +GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize= +→ data = { items:[{formUuid, code, name, appCode, version, audience[]}], total } +``` + +- 过滤链:`sch_id ∈ (schId, 0)` ∩ `is_deleted=0` ∩ `status=已发布` ∩ **调用方可读**(owner ∪ 显式 read grant), + **先过滤再分页**——`total` 是过滤后的条数,页数不随授权抖动; +- `page` 从 1 起算(缺省 1),`pageSize` 缺省 20、**上限 100**(超限直接拒,不静默收窄); +- `keyword` 对 `name`/`code` 做大小写不敏感的包含匹配,`appCode` 精确匹配(仅筛选); +- `version`=当前发布指针,目录只出已发布行所以必非空。消费方选中后**成对保存** `formUuid + version`; +- 不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是两个都合法的可选项(遮蔽规则属于按 code 的 resolve 入口)。 + +既有三个读端点同一批过 `requireRead`(签名通过不再等于全开): + +| 端点 | 判定时机 | +|---|---| +| `GET /oapi/form/schema?uuid=[&version=]` | 解析**之前**(手里有 uuid,被拒与不存在同句) | +| `GET /oapi/form/schema?code=[&appCode=]` | 解析之后、返回载荷之前(code 形态先要拿到 uuid) | +| `GET /oapi/form/answer?answerId=` | 定位答卷之后,拒绝时回答"答卷不存在" | +| `GET /oapi/form/answers?form_uuid=` | 游标解析之前 | + +CAS 自用填报路径不受影响:服务端应用 ACL 不替代填报入口的会话身份与行级数据范围(契约 v2 §8 那套仍在会话身份上)。 + +## §4 消费方(svc-hall / flow-center)保存什么 + +- 两端各出一个**同源目录代理**(管理面调用,浏览器不持 appKey/secret):校域由 CAS 会话派生,不由前端传; +- 只保存**服务器验证过的** `formUuid` + `formVersion`:不信自由输入的 `appCode`,也不信前端送来的版本号; +- flow-center 发布期调 `schema?uuid=&version=`,**返回坐标与请求不一致即阻断发布**;已发布快照不改写,改关联走新版本; +- svc-hall 事项保存时远端校验失败即整次保存失败(不写半个绑定)。 + +## §5 与旧默认开放行为的差异(上线必读) + +改造前:`/oapi/form/**` 只要**应用签名通过**,对声明域(`schId` 本校 ∪ 平台级)内的 schema/answer/answers **默认全开**, +没有表单级 ACL,也没有目录端点。改造后:**没有任何 owner 绑定或 grant 的表单,对外一律不可读**—— +包括此前"签名就能读"的存量表单。 + +⇒ **部署前必须逐张补 `form_ownership`(中心自有表)或 `form_consumer_grant`(跨应用消费)**,否则现有消费链路会读不到表。 +这是本批刻意选择的 fail-closed 取向(默认拒绝优先于兼容),不做"未配置=放行"的过渡开关。 + +**已知会在升级当场受影响的一条现成链路**:flow-center 的流程定义**发布期**快照(`FormCenterSchemaClient`, +C1 第一刀)现在就在校域内按 uuid 读 `/oapi/form/schema`。中心升级后若没有给 flow-center 的 appKey(联调态 100019) +补 owner 或 read grant,**发布会被拒**(表现为"快照拉取失败")。上线顺序因此固定为: +① 中心侧先建好绑定/授权(按消费应用逐个授 read,flow-center 与 svc-hall 各一条起)→ ② 中心发布 → ③ 消费方发布。 +svc-hall 侧本次是新增链路(改造前不读中心),所以只有"选不到表"这一种表现,不影响存量事项。 + +其余对外可见差异: +1. 新增 `GET /oapi/form/forms`(纯增量,不改变任何既有响应形状); +2. `schema`/`answer`/`answers` 的**成功响应逐字节不变**,只是多了一层拒绝; +3. 拒绝文案统一到"表单不存在/答卷不存在",原先"未发布/停用"这类差别信息只在**通过读权判定之后**才会给出。 + +## §6 管理面端点(守卫=校管理员 ∪ 超管,与 app 注册同一把) + +``` +GET /api/form/admin/form-access/owner?formUuid=&schId= → {formUuid, formSchoolId, ownerAppKey} | null +PUT /api/form/admin/form-access/owner?formUuid=&schId= body {ownerAppKey} +GET /api/form/admin/form-access/grants?formUuid=&schId= → [{formUuid, consumerSchoolId, consumerAppKey, + readAllowed, subscribeAllowed, enabled, grantVersion}] +POST /api/form/admin/form-access/grants?schId= body {formUuid, consumerSchoolId, consumerAppKey, + readAllowed, subscribeAllowed} +POST /api/form/admin/form-access/grants/revoke?schId= body {formUuid, consumerSchoolId, consumerAppKey} +``` + +- 校级管理员的管理域=本校:`consumerSchoolId` 不等于会话域即拒;超管可代某校授权/换绑; +- `consumerSchoolId` 必须 ≥1(0 是资源域不是租户);`readAllowed`/`subscribeAllowed` 至少开一把; +- appKey 写入前必须能在平台密钥注册表查到(否则"绑一个不存在的身份"会把资源挂到空气上),**响应不回显任何密钥**。 + +## §7 后续任务的唯一入口(不许另起一套 ACL) + +`FormConsumerAccessService` 是授权判定的唯一出口: + +- `ConsumerPrincipal resolveSignedCaller(String appKey)`(`ConsumerPrincipalResolver`) +- `void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)` +- `SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)` +- `List listEnabledSubscriptions(String formUuid, Long answerSchoolId)` + +TASK_2(Apply 输入换轨)与 TASK_3(事件投递)只调这四个接口;`SubscriptionGrant.grantVersion` 必须随投递下发, +消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。** diff --git a/docs/schema-extension-draft.md b/docs/schema-extension-draft.md new file mode 100644 index 0000000..2e209fb --- /dev/null +++ b/docs/schema-extension-draft.md @@ -0,0 +1,145 @@ +# 公共 schema 扩展契约草案(TASK_4) + +日期:2026-10-22。状态:**文档检查完成,候选功能未实施**。本文不批准SUR-T19/SUR-T20完整功能,不取消OPEN-T19b代提交;不修改校验器白名单、题型注册、统计或流程引擎。普通表单、匿名、自填与原有代填能力继续按现有代码解释。托管SH首批仅本人,是独立的既有边界。 + +## 1. 证据基线与当前实现 + +本次重新读取各子仓库HEAD及当前工作区,未创建提交: + +| 项目 | HEAD | +|---|---| +| FC后端 | `b2437a20742bfbfe38a564134904f3e0d7d931a3` | +| FC前端 | `2e406a88b1d9e09b2ca54a8cf68a3726174f49c0` | +| FL后端 | `2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f` | + +HEAD不包含Task1~3及本轮精修的未提交内容。下列公共校验常量/函数锚点从当前源码重新摘录;FL快照字段搬运也与`git show HEAD:...FormCenterSchemaClient.java`对照。改动过的管理/提交路径引用当前工作区,并在根目录`docs/tasks/evidence/refinement-20261022/`留源文件指纹,不能把工作区源码宣称为HEAD中已实现。 + +FC / `src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java` / `FormSchemaValidator#validateSchema`、`#validateField`: + +```java +if (!root.isObject() || !root.path("groups").isArray()) { + throw fail(null, "表单结构必须是 {\"groups\":[…]}"); +} +rejectUnknownProps(group, ALLOWED_GROUP_PROPS, "分组属性"); +rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")"); +``` + +这三句分属根对象、group循环、field方法,不是连续源码。根对象当前只检查对象及groups形状,**没有统一拒绝全部根级未知属性**;这不意味着未知规则会执行。group仅允许key/label/fields;field允许以下19项: + +```text +key,label,type,required,pattern,min,max,maxLength,tip,placeholder,options, +rows,defaultValue,accept,maxSize,maxFiles,precision,unit,searchable +``` + +FC / 同文件 / `FormSchemaValidator#validateOptions`:inline选项对象仅label/value;字典引用仅source且匹配`dict:`。字段valueSchema由读接口生成,不属于设计写入白名单。visible_when、matrix及任意脚本属性都不是已实现契约。 + +FC / 同文件 / `FormSchemaValidator#PHASE1_TYPES`: + +```java +public static final Set PHASE1_TYPES = new LinkedHashSet<>(Arrays.asList( + "text", "textarea", "number", "date", "select", "radio", "upload", + "checkbox", "multiselect", "phone", "image")); +``` + +address/user/users虽在14类冻结命名中,但未在一期放行。FC前端 / `src/types/formSchema.ts` / `PHASE1_TYPES`、`ALLOWED_FIELD_PROPS`及group/option/ref常量与之对应;`src/components/fields/index.ts` / `fieldWidget`、`widgetTypes`注册11类,app端组件仍有槽位限制,不能仅按目录存在就声明移动端完整可用。 + +FC / `src/main/java/com/yunhai/formcenter/common/FormValueValidator.java` / `FormValueValidator#validate`: + +```java +rejectUnknownKeys(data, fields); +if (isEmptyValue(value)) { + if (isRequired(field)) { + throw fail(field, key, "为必填项,未填写"); + } + continue; +} +validateValue(field, key, value, dictCandidates); +``` + +当前没有先执行visible_when的步骤。FC / `common/FormValueSchemaBuilder.java` / `withValueSchemas`在深拷贝上增加机读值形状,不改变版本schema原文;number输出number与边界,未生成precision对应multipleOf;多选输出array,不虚构uniqueItems;附件items闭集为id/fileName/key。机读片段不是完整的执行引擎替代物。 + +## 2. 扩展分类(未来候选,不是今天的兼容承诺) + +| 类别 | 候选承载方式 | 未来识别策略 | 当前事实 | +|---|---|---|---| +| 不可执行display metadata | 独立版本化命名空间,如`extensions.display`,只放纯文本/枚举/布局提示 | 明确允许的读写链路原样保留;不参与资格、值校验、路由、hash派生规则;所有展示转义 | 当前field/group未知属性仍拒绝,设计器会按白名单序列化;没有通用保留保证 | +| 可见性、必填联动、复合值约束等安全规则 | 明确schema契约版本及required capabilities | 写入/发布/提交/编辑/导入遇到不支持的必需规则必须拒绝,不能静默忽略 | 没有扩展能力协商或visible_when执行 | +| 生成的机读描述 | valueSchema及未来投影说明 | 从已核验schema派生,不能作为客户端绕过后端校验的权威 | valueSchema当前仅在读取时生成 | + +不使用eval、Function、JS脚本、任意表达式字符串或可调用URL。候选规则采用受限JSON AST,字段引用只能指向本表schema中的稳定key;运算符、类型、深度、节点数、引用数需白名单和预算。下列形状只是评审样例,**现有字段白名单不会接受它**: + +```json +{"key":"detail","type":"text","required":true, + "visible_when":{"op":"eq","field":"kind","value":"other"}} +``` + +将显示元数据和安全规则混放、遇到未知规则当作true、根据浏览器传来的visible结果跳过后端校验,均不属于候选契约。 + +## 3. 可见性、必填与隐藏值 + +未来统一顺序:按答卷formUuid/version取schema → 校验规则版本/类型/依赖 → 依拓扑顺序重算可见性 → 处理隐藏值策略 → 仅对可见字段执行required及值约束 → 附件归属/索引/存储。循环引用、未知key、类型错误、预算超限必须失败,不能转成“隐藏”。前端计算只用于体验,后端重算才是依据。 + +| 可见性结果 | required | 输入 | 候选处理 | +|---|---|---|---| +| visible | true | 缺失/null/类型约定的空值 | 拒绝必填空值;0与false不能当空值 | +| visible | false | 空 | 允许约定空值;是否缺键规范化为null须写入版本协议 | +| visible | 任意 | 非空 | 执行该题类型及值域校验 | +| hidden | 任意 | 无值 | 跳过required,不误报漏填 | +| hidden | 任意 | 有值 | 必须先定稿下表A/B策略,当前不选择、不实施 | +| 规则无效/结果未知 | 任意 | 任意 | 拒绝,返回稳定规则错误,不当作hidden | + +| 隐藏值候选 | A:拒绝携带 | B:服务端剥离 | +|---|---|---| +| 用户改上游答案使下游隐藏 | 需要客户端清理后重交;错误可诊断 | 服务端规范化后接收,须告知哪些字段被移除 | +| 审计 | 记录拒绝字段名及规则版本,不把敏感values写日志 | 记录剥离字段名及规则版本,不能伪装成用户主动清空 | +| 幂等/hash | 需明确同一请求的完整输入一致性 | 必须先规范化再生成存储hash,并区分原始请求指纹 | +| 本人/管理员编辑 | 不允许通过隐藏字段偷偷补值 | 不允许以管理员权限跳过规则而无单独协议 | +| 历史数据 | 不在读详情时破坏性重写旧答卷 | 同样不在读详情时自动剥离历史值;需要显式编辑/迁移审计 | + +A/B是实施前的业务决策,不能在开发中默认选一种。条件依赖于另一个隐藏字段时,该字段是缺失、历史保留还是不适用,也须一并定稿;禁止靠真假值隐式强转决定。 + +## 4. 普通题型与版本来源基线 + +| 项目/类#方法 | 当前值/版本事实 | 对扩展的边界 | +|---|---|---| +| FC `common/FormValueValidator#validateNumber` | JSON number,支持min/max、precision;number空值用null,不能用空字符串代替 | scale/矩阵不能借number类型塞数组或对象 | +| 同类 `#validateSingleOption` | select/radio为候选value字符串,存value而非label | 条件比较需比较稳定value,label变化不改历史语义 | +| 同类 `#validateMultiOption` | checkbox/multiselect为value字符串数组;逐项查候选,当前无显式去重拒绝 | 数组不得被FL按标量比较;不宣称JSONSchema有uniqueItems | +| 同类 `#validateAttachments`,`AnswerService`/`AnswerAdminService`附件归属检查 | upload/image为[{id,fileName,key}],服务端另核归属、key和大小/数量;值中不存URL | 隐藏附件的剥离、引用释放、历史下载权限须成对设计 | +| FC `service/AnswerService#doSubmit` | 登录普通提交按code/appCode解析发布版本;匿名走resolvePublishedForAnon,实际版本由服务端解析,不接受浏览器指定版本 | 未来普通提交须明确页面解析到写入之间的升版策略;不把hosted的钉版保证推到全部通道 | +| 同方法hosted分支,前端`views/Fill.vue#loadForm/#handleSubmit` | contextToken确定formUuid+formVersion,CAS确定本人及学校;后端按上下文读取确切schema | 扩展也必须按token版本,不能按中心最新schema计算可见性 | +| FC `service/AnswerImportService#publishedFields/#importAnswers` | 字段取当前发布版,并逐行走AnswerService.submit;并非已冻结整次导入的schema快照;upload/image列属于UNSUPPORTED_TYPES,不支持导入附件 | 未来需把预检与实际导入钉在同一版本,并与自填/编辑共用规则;不得把当前导入声称覆盖附件 | +| FC `service/AnswerReadService#myDetail/#editBySubmitter` | 按answer.formUuid/version读schema、编辑校验 | 不因当前表单升级而重解释旧答卷可见性 | +| FC `service/AnswerAdminService#edit` | 按答卷原版本校验并重建索引 | 管理员编辑不能另写显隐判据 | +| FC `service/AnswerExportService#fieldsForVersion/#fieldsByVersion` | 列头按当前版字段,逐答卷版本取字段解释值,缓存按version;标签/字典经expandSchema解析 | 并非已按历史版本并集导出全部已删题;schema原文钉版不等于外部字典label永远冻结 | +| FC `service/AnswerStatsService#stats/#groupField/#currentSchema` | 总数来自权限范围查询;字段元数据来自当前发布schema,按索引分组;没有题目可见性分母 | 不可把现有总数当作“看见该题人数”;跨版本同key统计须显式分组/映射 | + +表内路径均相对于form-center/service,前端另已标明。该表为源码基线,**本轮未执行上述题型的功能测试**。 + +## 5. 统计、导出和历史语义候选 + +未来分母至少分别表示:范围内答卷总数N、该题在其钉版schema存在的答卷E、规则计算可见的答卷V、有效作答A。缺失题/隐藏/可见未答/有效答案不能合并为一个null桶。V与A必须按每份答卷自己的schema版本和规则版本计算;不能把最新规则用于全部历史版本。 + +- 创建/匿名创建/本人编辑/管理员编辑/导入:同一规则服务与相同的空值、隐藏值策略;写后索引、outbox和hash基于实际存储值,不能只在前端隐藏。 +- 统计:题目存在比例用E/N,作答率用A/V(V=0时明确无定义而非除零);多选按答卷计可选次数,不能把选项次数当人数。矩阵每行分母、整题分母分别标注,不跨版本暗合并。 +- 导出:按答卷版本给出字段/行列标识及不适用标记;隐藏值是“不适用”还是历史保留受A/B策略与权限共同决定。不得在导出过程中顺手改写答卷。 +- 编辑:按原版本重算可见性;如果获批升级答卷schema,必须独立迁移任务、有原/新版本及审计,不混入普通编辑。 +- 状态修改/软删:已有revision/outbox流程继续使用;规则版本与可见性派生数据是否改变快照/hash需后续协议显式升级,不能原地变更fc.canon.v1。 + +## 6. 矩阵/量表候选 + +未冻结matrix/scale题型,也未实现渲染、存储、校验或统计。若获批,先定义稳定rowKey/columnValue及整题显隐;不能靠每个单元格的动态隐藏解释同一复合值。 + +```json +{"satisfaction":{"row_service":"good","row_speed":"average"}} +``` + +上例仅为object值的候选;也可评审显式array结构,但须择一版本化,不能同时接受多种模糊编码。unknown row/column、重复row、必答行缺失、额外键、空行与不适用行的行为均须有真值表。复合值到FL必须拒绝作为标量路由,或经获批、显式、版本化的投影变成单一键/数值/枚举。 + +## 7. 下一批实施与资源边界 + +下批应成对分配FC校验器/前端白名单/渲染/提交编辑导入/统计导出与FL快照兼容,文件与任务拆分见FL伴随文档。候选预算可先评审:规则深度8、节点64、引用32,矩阵行列各50、单题2500单元格;这些是待定资源上限,不是当前代码保证。超限必须静态拒绝,不执行递归无界计算。 + +实施前待定:A/B隐藏值策略、规则JSON版本、依赖隐藏字段语义、字典历史label、矩阵结构、投影命名/空值/值域及统计口径。未决项不影响本次只交付草案,但禁止据草案直接编码候选功能。 + +验收状态:文档边界/样例/真值表交叉检查完成;FormSchemaValidatorTest等题型测试未运行(遵循本轮用户仅编译/打包要求)。本次相关后端打包只证明编译及构建,不能证明visible_when/matrix支持。 diff --git a/sql/answer_delivery_v2.sql b/sql/answer_delivery_v2.sql new file mode 100644 index 0000000..0b7415f --- /dev/null +++ b/sql/answer_delivery_v2.sql @@ -0,0 +1,105 @@ +-- ============================================================ +-- 表单中心 v2 · 快照投递与可信提交上下文 DDL(MySQL 5.7 兼容复核版,TASK_2) +-- 范围:《answer-delivery-v2.md》§2/§3/§6 = answer.answer_revision 加列 + 建两张表 +-- answer_delivery(outbox)/ submission_context(可信提交上下文) +-- 依据:任务书 TASK_2 B.1(单调修订号与不可变 outbox)、B.3(逐授权订阅一行)、 +-- A.2/A.3(令牌摘要与意图↔答卷一对一原子关联) +-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 +-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v3/t16 先例)。 +-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 +-- CHARSET/COLLATE;③不建物理外键——answer_uuid / form_uuid 都是逻辑引用 +-- (答卷无物理删除但表单可软删,投递行与上下文必须能在其之后继续存在以支撑溯源); +-- ④唯一键里的 answer_revision 是 BIGINT,不占 utf8mb4 长度预算。 +-- 身份口径:consumer_app_key / source_app_key 存平台 appKey(UserBase 注册分配、 +-- 验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug—— +-- 资源 appCode 当消费者会把"同应用多消费方"合并成一行(任务书 B.3 点名禁止)。 +-- 双时刻纪律:broker_accepted_at 与 consumer_applied_at 是两列、两个写入方, +-- 前者只由 confirm 回调写、后者只由签名回执端点写;任何一侧都不许替另一侧作证(B.4/B.6)。 +-- 存量:answer.answer_revision 默认 0,不回填历史版本、不建双轨(任务书 A.5 末句)—— +-- 存量行此后第一次变更取号 1,此前的对外快照本来就没有 hash 事实,回填等于伪造历史。 +-- 非幂等:MySQL 5.7 无评审友好的 ADD/CREATE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。 +-- 执行前先 `SHOW TABLES LIKE 'answer_delivery'` / `'submission_context'` 确认不存在, +-- 并 `SHOW COLUMNS FROM answer LIKE 'answer_revision'` 确认列不存在。 +-- ============================================================ + +ALTER TABLE `answer` + ADD COLUMN `answer_revision` BIGINT NOT NULL DEFAULT 0 + COMMENT '对外快照修订号:单调递增,凡改变对外快照的路径(创建/值编辑/改状态/软删,含批量)在同一事务内 +1 并写 outbox;与钉版 version 是两个维度'; + +CREATE TABLE `answer_delivery` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `event_id` VARCHAR(36) NOT NULL COMMENT '信封事件 id(uuid):broker 相关键与消费端幂等键同一件', + `protocol_version` VARCHAR(32) NOT NULL COMMENT '固定 fc.answer.snapshot.v2(锁死 fc.canon.v1 哈希口径)', + `event_type` VARCHAR(16) NOT NULL COMMENT 'created / updated / tombstoned(值改与状态改同归 updated)', + `source_app_key` VARCHAR(64) DEFAULT NULL COMMENT '发布方应用身份(服务端配置,不接受消费方申报)', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '投递目标:显式消费应用平台 appKey', + `consumer_school_id` BIGINT NOT NULL COMMENT '投递目标租户域(与 appKey 一起构成授权坐标,不是资源归属校)', + `grant_version` INT NOT NULL COMMENT '入队时刻的授权纪元;发布前再核一次,进位或撤销即不重放', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标(逻辑引用,不建 FK)', + `form_version` INT NOT NULL COMMENT '答卷钉住的 schema 版本(与 answer.version 同值)', + `answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid(对外身份,库内 id 不外露)', + `answer_revision` BIGINT NOT NULL COMMENT '本行对应的快照修订号(与 answer.answer_revision 同值)', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑标记;软删行的快照照常投递,靠它表达删除', + `content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 下核心快照(剔除 intent)的 SHA-256 十六进制小写', + `occurred_at` DATETIME NOT NULL COMMENT '业务发生时刻(= 答卷 update_time;投递时间不在此列)', + `snapshot_json` LONGTEXT NOT NULL COMMENT '不可变载荷:该消费方看到的完整快照(含仅对其可见的 intent 关联块)', + `state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING / BROKER_ACCEPTED / FAILED / DEAD / GRANT_REVOKED', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '投递元数据:重试计数不进快照、不参与哈希', + `next_retry_at` DATETIME DEFAULT NULL COMMENT '退避到点才再扫(base×2^n 封顶 1h),DEAD 置 NULL 不再自动重投', + `broker_accepted_at` DATETIME DEFAULT NULL COMMENT '总线 confirm(ack=true) 时刻——只证明总线收下,不证明业务应用', + `consumer_applied_at` DATETIME DEFAULT NULL COMMENT '消费方签名回执落定时刻(只有回执端点写得了)', + `consumer_outcome` VARCHAR(16) DEFAULT NULL COMMENT 'APPLIED / IGNORED / CONFLICT;重复上报不同结论时保留首次', + `last_replay_operation_id` VARCHAR(64) DEFAULT NULL COMMENT '最近一次授权重投的操作幂等键(同键不重复刷新退避)', + `last_error` VARCHAR(500) DEFAULT NULL COMMENT '末次失败原因或回执 localRef(截断存)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_delivery_event` (`event_id`), + UNIQUE KEY `uk_delivery_revision` (`answer_uuid`, `answer_revision`, `consumer_app_key`, `consumer_school_id`), + KEY `idx_delivery_scan` (`state`, `next_retry_at`), + KEY `idx_delivery_consumer` (`consumer_app_key`, `consumer_school_id`, `id`), + KEY `idx_delivery_answer` (`answer_uuid`, `answer_revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='答卷快照 outbox(v2 §3:与答卷同事务落库、逐授权订阅一行、双时刻分列;不提供任何删除方法)'; + +CREATE TABLE `submission_context` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用平台 appKey(取自已验签的 yh.app.auth,非请求体申报)', + `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(≥1;签发时即过 read+subscribe 两把授权)', + `intent_id` VARCHAR(64) NOT NULL COMMENT '消费方业务意图键(服务端生成;浏览器不可注入)', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '钉定的表单 uuid(成对校验过版本行才落)', + `form_version` INT NOT NULL COMMENT '钉定的确切版本号(hosted 永不回落到当前发布版)', + `app_code` VARCHAR(64) NOT NULL COMMENT '解析出来的资源 appCode(提交时按它寻址,不再走 code 遮蔽)', + `code` VARCHAR(64) NOT NULL COMMENT '解析出来的表单 code(与 app_code 一起构成提交体坐标核对)', + `submitter_id` INT NOT NULL COMMENT '消费方服务端断言的填报人;兑换时与 CAS 会话逐字复核', + `token_digest` CHAR(64) NOT NULL COMMENT 'contextToken 的 SHA-256(明文不入库,只在签发响应里出现一次)', + `embed_origin` VARCHAR(200) NOT NULL COMMENT '可嵌入父页面精确 origin(服务端配置锁定,被嵌页 postMessage 的唯一目标)', + `callback_url` VARCHAR(500) NOT NULL COMMENT '消费方确认入口绝对 URL(服务端配置;浏览器传入的一律不采信)', + `expires_at` DATETIME NOT NULL COMMENT '有效期(默认 30 分钟,form-center.hosted-fill.token-ttl-minutes)', + `answer_uuid` VARCHAR(32) DEFAULT NULL COMMENT '答卷原子关联(提交事务内写;NULL=尚未填报。一个意图只挂一份答卷)', + `linked_revision` BIGINT DEFAULT NULL COMMENT '关联时刻的快照修订号(收据与投影合并的判据之一)', + `linked_at` DATETIME DEFAULT NULL COMMENT '关联时刻', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ctx_token_digest` (`token_digest`), + UNIQUE KEY `uk_ctx_intent` (`consumer_app_key`, `intent_id`), + UNIQUE KEY `uk_ctx_answer` (`answer_uuid`), + KEY `idx_ctx_form` (`form_uuid`, `form_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='可信提交上下文(v2 §6:意图↔答卷一对一、令牌只存摘要、坐标与 origin/回调都在签发时锁死)'; + +-- 自验(执行后逐条核对): +-- SHOW CREATE TABLE answer_delivery\G -- 应含 uk_delivery_event / uk_delivery_revision 两个唯一键 +-- SHOW CREATE TABLE submission_context\G -- 应含 uk_ctx_intent(同意图不新建第二行)与 uk_ctx_answer +-- SHOW COLUMNS FROM answer LIKE 'answer_revision'; -- 必须存在且 NOT NULL DEFAULT 0 +-- SELECT COUNT(*) FROM information_schema.columns +-- WHERE table_schema=DATABASE() AND table_name IN ('answer_delivery','submission_context') +-- AND column_name REGEXP 'secret|token$|retry_policy'; -- 必须 0 行(密钥与明文令牌都不入库) +-- SELECT COUNT(*) FROM information_schema.referential_constraints +-- WHERE table_name IN ('answer_delivery','submission_context'); -- 必须 0 行(无物理外键) +-- SELECT COUNT(*) FROM answer WHERE answer_revision <> 0; -- 刚执行完必须 0(不回填历史) diff --git a/sql/answer_reconcile_v1.sql b/sql/answer_reconcile_v1.sql new file mode 100644 index 0000000..39a0fc3 --- /dev/null +++ b/sql/answer_reconcile_v1.sql @@ -0,0 +1,128 @@ +-- ============================================================ +-- 表单中心 · 差异对账任务 DDL(MySQL 5.7 兼容复核版,TASK_3) +-- 范围:answer-reconcile-v1 §2/§3/§5 = 三张新表 +-- answer_reconcile_task(任务)/ answer_reconcile_manifest_row(不可变清单)/ +-- answer_reconcile_result(结果回报) +-- 依据:任务书 TASK_3 改动点 1/2/4/8;不复用 answer_op_log 当任务表、不动 answer/answer_delivery。 +-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 +-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v2/v3 先例)。 +-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE; +-- ③不建物理外键——task_uuid 是逻辑引用,清单与结果要能在任务行之后继续存在以支撑复盘; +-- ④清单游标与本地扫描游标同为 answer_uuid(32 位小写十六进制),general_ci 下与 binary 序一致。 +-- 身份口径:consumer_app_key 存平台 appKey(验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug。 +-- 审计纪律:三张表都没有删除路径(服务层与仓库层都不提供 delete),终态留表供复盘。 +-- 非幂等:MySQL 5.7 无评审友好的 CREATE TABLE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。 +-- 执行前先 `SHOW TABLES LIKE 'answer_reconcile%'` 确认三张表都不存在。 +-- ============================================================ + +CREATE TABLE `answer_reconcile_task` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `task_uuid` VARCHAR(36) NOT NULL COMMENT '任务对外身份(服务端 UUID,≤36 字符;URL 与指令都用它)', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '对账目标的消费应用平台 appKey(不是 app_code slug)', + `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域:与投递行同口径,也就是答卷归属校', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '本轮范围=一张表单;全量=按生效订阅 fission 成多行任务', + `form_version` INT NOT NULL COMMENT '物化时核验过的发布版本(缺席答复与修复信封的 formVersion 权威值)', + `trigger_type` VARCHAR(16) NOT NULL COMMENT 'SCHEDULE / MANUAL(列名避开 MySQL 保留字 TRIGGER)', + `operator` VARCHAR(64) DEFAULT NULL COMMENT '人工触发时的操作人(调度产生的为 NULL)', + `idempotency_key` VARCHAR(128) NOT NULL COMMENT '日期+订阅+范围唯一键:调度重复跑不出第二行任务', + `state` VARCHAR(16) NOT NULL DEFAULT 'QUEUED' COMMENT 'QUEUED/DISPATCHED/RUNNING/COMPLETED/PARTIAL/FAILED/CANCELLED', + `manifest_state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/MATERIALIZING/COMPLETE/FAILED:派发只在 COMPLETE 后', + `manifest_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '清单结束标记:只有它为 1,"清单里没有"才可能是权威缺席', + `total_rows` INT NOT NULL DEFAULT 0 COMMENT '物化完成后=清单行数(上限见 fc.answer.reconcile.max-rows)', + `total_pages` INT NOT NULL DEFAULT 0 COMMENT '确定总页(ceil(total_rows/page_size),不是"某页空了")', + `materialized_rows` INT NOT NULL DEFAULT 0 COMMENT '已写入清单行数(进度与断点续作的计数)', + `materialized_pages` INT NOT NULL DEFAULT 0 COMMENT '已物化页数', + `checkpoint_cursor` VARCHAR(32) DEFAULT NULL COMMENT '物化检查点:已写到的最后一个 answer_uuid(重启从这里续)', + `window_start` DATETIME NOT NULL COMMENT '任务起点=一致读事务开始时刻;晚于此刻的本地行不得判为多余行', + `window_end` DATETIME DEFAULT NULL COMMENT '清单物化完成时刻(窗口长度给运维复盘用)', + `grant_version` INT NOT NULL DEFAULT 1 COMMENT '物化时刻的授权纪元;每轮读取再核一次,进位即按撤权处置', + `attempt` INT NOT NULL DEFAULT 0 COMMENT '派发尝试次数(首派 0;租约超期或派发失败后 +1,有上限)', + `lease_until` DATETIME DEFAULT NULL COMMENT '派发/物化租约到期时刻:超期由扫描按 attempt+1 有限退避接手', + `next_dispatch_at` DATETIME DEFAULT NULL COMMENT '退避到点才再派(base×2^n 封顶),耗尽即 FAILED', + `dispatched_at` DATETIME DEFAULT NULL COMMENT '对端 2xx 收下指令的时刻(不代表执行完成,终态只看 result 回报)', + `finished_at` DATETIME DEFAULT NULL COMMENT '终态落定时刻', + `cancel_requested` TINYINT NOT NULL DEFAULT 0 COMMENT '取消请求:置 1 后清单/快照读取一律以取消口径答复,消费端据此停止剩余修复', + `error_code` VARCHAR(32) DEFAULT NULL COMMENT '稳定错误码(GRANT_REVOKED/RESOURCE_LIMIT/MANIFEST_INCOMPLETE/…)', + `error_detail` VARCHAR(500) DEFAULT NULL COMMENT '末次错误摘要(截断存;答卷值文档绝不进这里)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_reconcile_task_uuid` (`task_uuid`), + UNIQUE KEY `uk_reconcile_task_idem` (`idempotency_key`), + KEY `idx_reconcile_dispatch` (`state`, `next_dispatch_at`), + KEY `idx_reconcile_consumer` (`consumer_app_key`, `consumer_school_id`, `id`), + KEY `idx_reconcile_coord` (`consumer_app_key`, `consumer_school_id`, `form_uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='差异对账任务(TASK_3 §2:一行=一个 (消费应用, 消费租户域, 表单) 坐标上的一次对账企图;不是投递行、不是答卷、不是 op_log)'; + +CREATE TABLE `answer_reconcile_manifest_row` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `task_id` BIGINT NOT NULL COMMENT '逻辑引用 answer_reconcile_task.id(不建物理外键)', + `task_uuid` VARCHAR(36) NOT NULL COMMENT '任务身份冗余一份:清单行脱离任务上下文也要能自证是哪一轮', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '物化时核验过的表单坐标', + `form_version` INT NOT NULL COMMENT '该答卷钉住的 schema 版本(= answer.version,不从旧清单猜版本)', + `answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid:清单游标与消费端本地游标同为它', + `revision` BIGINT NOT NULL COMMENT '物化时刻的对外修订号(含 0:存量未变更行原样入清单,修复端点会拒绝并只报告)', + `content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 摘要(与投递行同一条规范化实现,本表不复制算法)', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑也进清单:"缺席"与"存在但已删"是两个不同结论', + `seq_no` INT NOT NULL COMMENT '物化写入序号(审计与断点续作的序号锚;分页游标不是它)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_manifest_row` (`task_id`, `answer_uuid`), + KEY `idx_manifest_page` (`task_id`, `answer_uuid`), + KEY `idx_manifest_seq` (`task_id`, `seq_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='任务不可变清单(TASK_3 §3:一致读事务内物化四元摘要含墓碑;此后分页只读它,全列 updatable=false,无 update/delete 方法)'; + +CREATE TABLE `answer_reconcile_result` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `task_id` BIGINT NOT NULL COMMENT '逻辑引用 answer_reconcile_task.id', + `task_uuid` VARCHAR(36) NOT NULL COMMENT '任务身份(回报定位用)', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '回报来源:必须等于已验签身份,自报不符即拒', + `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(跨校回报的判据之一)', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '范围坐标(每条错误与计数都能定位到这里)', + `attempt` INT NOT NULL COMMENT '对应的派发尝试;比最新 attempt 更旧的回报不推进状态(不倒退新 attempt)', + `state` VARCHAR(16) NOT NULL COMMENT 'COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报)', + `local_scan_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '消费端本地扫描是否整轮完成(多余行判定的前提之一)', + `manifest_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '消费端实际读到的中心结束标记(与中心不一致则本轮结论不成立)', + `scanned_rows` INT NOT NULL DEFAULT 0 COMMENT '比较过的答卷数', + `pages_processed` INT NOT NULL DEFAULT 0 COMMENT '处理页数', + `missing_repaired` INT NOT NULL DEFAULT 0 COMMENT '缺失修复数(repairAuthoritative=APPLIED,期望三元组全 null)', + `stale_repaired` INT NOT NULL DEFAULT 0 COMMENT '陈旧修复数(本地 revision 更低)', + `hash_conflicts` INT NOT NULL DEFAULT 0 COMMENT '同 revision 异 hash:记录后按中心当前值修复', + `tombstones_repaired` INT NOT NULL DEFAULT 0 COMMENT '墓碑修复数(同一权威修复原语,保留本地业务扩展)', + `local_ahead` INT NOT NULL DEFAULT 0 COMMENT '本地 revision 更高:只向中心复核,禁止降级覆盖', + `deferred` INT NOT NULL DEFAULT 0 COMMENT '授权核验不可达/撤权/纪元不等而暂停的条目', + `extra_reported` INT NOT NULL DEFAULT 0 COMMENT '只报告的多余行(含期间新增、createTime 未知、范围不明)', + `extra_retired` INT NOT NULL DEFAULT 0 COMMENT '经 FC 签名缺席证明实际退役的行数(无证明即不增)', + `error_count` INT NOT NULL DEFAULT 0 COMMENT '错误条目总数(超上限会截断,明细以 errors_json 为准)', + `errors_json` MEDIUMTEXT NOT NULL COMMENT '错误明细(有上限、截断存;每条含 consumer/task/range/answer 四坐标,不含 values)', + `resumed` TINYINT NOT NULL DEFAULT 0 COMMENT '1=进程重启后从检查点续跑的同一 attempt 再报', + `reported_at` DATETIME NOT NULL COMMENT '中心受理该回报的时刻', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_result_attempt` (`task_id`, `attempt`), + KEY `idx_result_task` (`task_id`, `attempt`), + KEY `idx_result_consumer` (`consumer_app_key`, `consumer_school_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='对账结果回报(TASK_3 §5:(task_id, attempt) 唯一=幂等锚点;一 attempt 一行不覆盖,APPLIED 类计数不代表建件或流程成功)'; + +-- 自验(执行后逐条核对): +-- SHOW CREATE TABLE answer_reconcile_task\G -- 应含 uk_reconcile_task_idem(同日同订阅同范围只一行) +-- SHOW CREATE TABLE answer_reconcile_manifest_row\G -- 应含 uk_manifest_row(同任务同答卷不重复入清单) +-- SHOW CREATE TABLE answer_reconcile_result\G -- 应含 uk_result_attempt(重复回报幂等吸收的库级形态) +-- SELECT COUNT(*) FROM information_schema.referential_constraints +-- WHERE table_name IN ('answer_reconcile_task','answer_reconcile_manifest_row','answer_reconcile_result'); +-- -- 必须 0 行(无物理外键) +-- SELECT COUNT(*) FROM information_schema.columns +-- WHERE table_schema=DATABASE() AND table_name='answer_reconcile_result' +-- AND column_name IN ('values_json','snapshot_json','secret','token'); +-- -- 必须 0 行(值文档与密钥不进对账留痕) diff --git a/sql/form_consumer_access_v3.sql b/sql/form_consumer_access_v3.sql new file mode 100644 index 0000000..7e16761 --- /dev/null +++ b/sql/form_consumer_access_v3.sql @@ -0,0 +1,64 @@ +-- ============================================================ +-- 表单中心 v3 · 消费应用授权 DDL(MySQL 5.7 兼容复核版,TASK_1) +-- 范围:《consumer-access-v3.md》§2/§3 = 建两张表 form_ownership / form_consumer_grant +-- 依据:VERIFICATION V03/V04/V07;owner 绑定与 grant 分权(读/订阅)、默认拒绝、撤销留痕 +-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate +-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3/t10/t16 先例)。 +-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE; +-- ③不建物理外键——form_uuid 对 form 是逻辑引用(同 t16 对 app_code 的处理: +-- 表单软删是唯一切除形态且无物理删除,绑定/授权行必须能在表单软删后继续存在以支撑溯源, +-- 建 FK 等于把"级联删"写进库侧规则);④无可空业务列(判权路径不写"未知=怎样"的分支)。 +-- 身份口径:owner_app_key / consumer_app_key 存的是平台 appKey(UserBase 应用注册分配, +-- 验签三件套里那个 X-App-Key),不是 form.app_code 那种人读 slug。 +-- app_registry 仍是快应用目录,本批一行不加(v3 §1 红线:不把业务授权塞回注册表)。 +-- 租户口径:consumer_school_id 既是授权生效域,也是读答卷时的行集限定域;0 不是合法消费域 +-- (平台级是资源域,不是一个能答卷的租户),服务侧直接拒。 +-- 默认拒绝:两把 *_allowed 开关默认 0,enabled 默认 1(建行即生效,但建之前服务侧必须显式给开关); +-- 撤销是 enabled=0 + grant_version 进位,不删行(判权 SQL 必须带 enabled 条件)。 +-- 非幂等:MySQL 5.7 无 ADD/CREATE IF NOT EXISTS 的评审友好写法(写了反而掩盖"表已存在但列不对"的漂移)。 +-- 执行前先 `SHOW TABLES LIKE 'form_ownership'` / `'form_consumer_grant'` 确认不存在; +-- 存量:系统未上线,两张表零存量、无需回填(与 t16 同一论证)。 +-- ============================================================ + +CREATE TABLE `form_ownership` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标:表单全局 uuid(契约 §1,不可枚举、跨版本不变)', + `form_school_id` BIGINT NOT NULL COMMENT '绑定时表单自己的 sch_id;0=平台级表单', + `owner_app_key` VARCHAR(64) NOT NULL COMMENT '资源拥有者应用的平台 appKey(写入前已核验 UserBase 注册身份)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ownership_form` (`form_uuid`), + KEY `idx_ownership_owner` (`owner_app_key`, `form_school_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='表单资源拥有者绑定(v3 §2:一张表一个 owner,只能由管理面写;form_uuid 为逻辑引用不建 FK)'; + +CREATE TABLE `form_consumer_grant` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `form_uuid` VARCHAR(32) NOT NULL COMMENT '被授权的表单 uuid', + `consumer_school_id` BIGINT NOT NULL COMMENT '消费租户(授权生效域 = 读答卷的行集限定域,≥1)', + `consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用的平台 appKey', + `read_allowed` TINYINT NOT NULL DEFAULT 0 COMMENT '读授权:目录/schema/answer/answers;默认拒绝', + `subscribe_allowed` TINYINT NOT NULL DEFAULT 0 COMMENT '订阅授权:答卷变更事件投递(owner 不天然持有)', + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '生效开关;0=已撤销(保留行供审计与 grant_version 判新旧)', + `grant_version` INT NOT NULL DEFAULT 1 COMMENT '授权版本号:撤销与重申都进位,投递侧必须带它下发', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_grant` (`form_uuid`, `consumer_school_id`, `consumer_app_key`), + KEY `idx_grant_consumer` (`consumer_app_key`, `consumer_school_id`, `enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='消费应用对某张表单的显式授权(v3 §2:三元唯一、读与订阅分权、默认拒绝、撤销不删行)'; + +-- 自验(执行后逐条核对): +-- SHOW CREATE TABLE form_ownership\G -- 应含 UNIQUE KEY `uk_ownership_form` (`form_uuid`) +-- SHOW CREATE TABLE form_consumer_grant\G -- 应含 UNIQUE KEY `uk_grant` (三元键) 与 idx_grant_consumer +-- SELECT COUNT(*) FROM information_schema.columns +-- WHERE table_schema=DATABASE() AND table_name IN ('form_ownership','form_consumer_grant') +-- AND column_name REGEXP 'secret|app_code|role|perm'; -- 必须 0 行(密钥与 app_code 都不进授权表) +-- SELECT COUNT(*) FROM information_schema.referential_constraints +-- WHERE table_name IN ('form_ownership','form_consumer_grant'); -- 必须 0 行(无物理外键) diff --git a/src/main/java/com/yunhai/formcenter/config/AnswerDeliveryConfiguration.java b/src/main/java/com/yunhai/formcenter/config/AnswerDeliveryConfiguration.java new file mode 100644 index 0000000..f758cd6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/config/AnswerDeliveryConfiguration.java @@ -0,0 +1,37 @@ +package com.yunhai.formcenter.config; + +import com.yunhai.formcenter.service.AnswerDeliveryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * 快照投递的运行期装配(answer-delivery-v2 §3.4,TASK_2 B.4)。 + * + *

{@code @EnableScheduling} 落在本类而不是应用主类:{@code FormCenterApplication} 不在本任务 + * 写范围内,而 v2 的退避重投必须有周期扫描(broker 恢复后要把 PENDING/FAILED 追平)。

+ * + *

扫描不是持久性机制——持久性是 outbox 行本身(同事务落库)。这里只是把"到点的行" + * 推给 broker;{@code afterCommit} 的即时唤醒({@link AnswerDeliveryService#wakeScan()})负责低延迟, + * 本方法负责兜底。未配 broker 时 {@code sendDueBatchQuietly} 直接返回 0,行留在库里。

+ */ +@Slf4j +@Configuration +@EnableScheduling +@RequiredArgsConstructor +public class AnswerDeliveryConfiguration { + + private final AnswerDeliveryService deliveryService; + + /** 到点重投(退避序列由投递行的 next_retry_at 表达,这里只负责"闹钟") */ + @Scheduled(fixedDelayString = "${fc.answer.delivery.scan-interval-ms:10000}", + initialDelayString = "${fc.answer.delivery.scan-initial-delay-ms:30000}") + public void scanDueDeliveries() { + int sent = deliveryService.sendDueBatchQuietly(); + if (sent > 0 && log.isInfoEnabled()) { + log.info("[v2投递] 周期扫描已交出去 {} 条(等 broker confirm)", sent); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java b/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java new file mode 100644 index 0000000..11ceb0b --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java @@ -0,0 +1,96 @@ +package com.yunhai.formcenter.config; + +import com.yunhai.formcenter.service.AnswerReconcileService; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * 差异对账的运行期装配(TASK_3,answer-reconcile-v1 §7)。 + * + *

本任务的配置全部独立:键前缀 {@code fc.answer.reconcile.*},不写 {@code fc.answer.delivery.*} + * (TASK_2)、不写 {@code form-center.*}(TASK_1)的键,也不改那两个任务的任何文件。 + * {@code @EnableScheduling} 早已由 {@link AnswerDeliveryConfiguration} 打开,本类只加自己的闹钟。

+ * + *

两个开关默认都是关的:{@code schedule-enabled}(每日建任务)与 {@code dispatch-enabled} + * (物化+派发循环)。未授权订阅不产生任务,而未开调度同样不产生任务——调度可显式关闭这件事 + * 是任务书的前置条件,不是可选优化。 {@code targets} 是服务端登记的消费方后台地址表, + * 键=已验签的 consumerAppKey:派发地址只能从这里来,浏览器/请求体里出现的任何 callbackUrl + * 在本任务里没有对应字段,也就无从采信。

+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class AnswerReconcileConfiguration { + + private final AnswerReconcileService reconcileService; + + @Bean + @ConfigurationProperties(prefix = "fc.answer.reconcile") + public AnswerReconcileProperties answerReconcileProperties() { + return new AnswerReconcileProperties(); + } + + /** 清单物化与派发共用一个扫描闹钟:每 tick 最多推进一条任务(这就是任务级单并发) */ + @Scheduled(fixedDelayString = "${fc.answer.reconcile.scan-interval-ms:15000}", + initialDelayString = "${fc.answer.reconcile.scan-initial-delay-ms:45000}") + public void scanReconcileTasks() { + AnswerReconcileProperties properties = reconcileService.properties(); + if (!properties.isDispatchEnabled()) { + return; // 默认停用:建任务与查看结果仍可用,但不物化、不派发、不修数据 + } + int materialized = reconcileService.materializeNextQuietly(); + int dispatched = reconcileService.dispatchNextQuietly(); + int recovered = reconcileService.recoverExpiredLeasesQuietly(); + if (materialized + dispatched + recovered > 0 && log.isInfoEnabled()) { + log.info("[对账] 本轮物化 {} 任务、派发 {} 任务、租约回收 {} 任务", materialized, dispatched, recovered); + } + } + + /** 对账运行参数({@code fc.answer.reconcile.*}) */ + @Getter + @Setter + public static class AnswerReconcileProperties { + + // ===== 开关(默认全关,见类头)===== + private boolean scheduleEnabled = false; + private boolean dispatchEnabled = false; + + // ===== 资源上限:超限拒绝建任务并留下原因,绝不当场无界载表 ===== + /** 单任务清单行数上限 */ + private int maxRows = 20000; + /** 单任务清单页数上限(与 maxRows 双闸:页大小被调大时它兜底) */ + private int maxPages = 200; + /** 一次调度/一次全量人工触发最多裂变出多少任务 */ + private int maxTasksPerRun = 20; + /** 清单分页与建议扫描页大小(消费端可在请求里收窄,上限 500) */ + private int pageSize = 200; + /** 结果回报里最多带多少条错误明细、明细 JSON 最多多少字符 */ + private int maxErrorItems = 50; + private int maxErrorChars = 8000; + + // ===== 派发与租约 ===== + /** 租约时长(秒):消费端要在这段时间内回报进度,否则按 attempt+1 重派 */ + private int leaseSeconds = 900; + /** 有限退避重派次数(含首派);耗尽即 FAILED,留可查记录,不无限重投 */ + private int maxDispatchAttempts = 4; + private int backoffBaseSeconds = 60; + private int backoffCapSeconds = 1800; + /** 消费方后台服务地址表:键=consumerAppKey(已验签身份),值=带 context-path 的完整基址 */ + private java.util.Map targets = new java.util.LinkedHashMap<>(); + /** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */ + private String dispatchPath = "/oapi/form-sync/reconcile"; + + /** 信封 sourceAppKey:默认取本应用签名身份 */ + private String sourceAppKey = ""; + + /** 扫描闹钟节奏(占位符与本类 @Scheduled 共用同一批键) */ + private long scanIntervalMs = 15000L; + private long scanInitialDelayMs = 45000L; + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAccessController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAccessController.java new file mode 100644 index 0000000..98c4101 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAccessController.java @@ -0,0 +1,111 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.formcenter.model.FormAccessModels.GrantRequest; +import com.yunhai.formcenter.model.FormAccessModels.GrantView; +import com.yunhai.formcenter.model.FormAccessModels.OwnerRequest; +import com.yunhai.formcenter.model.FormAccessModels.OwnershipView; +import com.yunhai.formcenter.model.FormAccessModels.RevokeRequest; +import com.yunhai.formcenter.service.ConsumerPrincipalResolver; +import com.yunhai.formcenter.service.FormAccessPolicy; +import com.yunhai.formcenter.service.FormAdminGuard; +import com.yunhai.formcenter.service.FormConsumerAccessService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 消费应用授权管理面(v3 §2/§3,TASK_1)。 + * + *
+ * GET  /api/form/admin/form-access/owner          → 这张表的资源拥有者(未绑定回 null)
+ * PUT  /api/form/admin/form-access/owner          → 设/换 owner 绑定(appKey 必须已在平台注册)
+ * GET  /api/form/admin/form-access/grants         → grant 清单(含已撤销行)
+ * POST /api/form/admin/form-access/grants         → 授权/重申(读与订阅两把开关)
+ * POST /api/form/admin/form-access/grants/revoke  → 撤销(enabled=0 + grantVersion 进位)
+ * 
+ * + *

守卫沿用既有那把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③, + * 与 app 注册、管理员两表同域),不新造一套管理面鉴权。为什么不是"创建人也能改": + * owner/grant 决定的是别的能不能读这张表,属于平台管理域而不是单张表的定义权—— + * 创建人能把表建起来、发出去,但不能替全校做"把答卷给外部应用看"这个决定。

+ * + *

身份核验的正向路径(v3 §2):写进来的 appKey 必须是 UserBase 注册过的应用 + * ({@link ConsumerPrincipalResolver#isRegisteredAppKey} 查平台密钥注册表,只判存在、不回显密钥)。 + * 签名应用没有自报 owner 的通道——这张表上没有"应用自己声明我拥有某张表单"的入口。

+ */ +@RestController +@RequestMapping("/api/form/admin/form-access") +@RequiredArgsConstructor +public class FormAccessController { + + private final FormAdminGuard adminGuard; + private final FormConsumerAccessService accessService; + private final ConsumerPrincipalResolver callerResolver; + + /** owner 绑定(读) */ + @GetMapping("/owner") + public OwnershipView owner(BoaUser user, + @RequestParam("formUuid") String formUuid, + @RequestParam(required = false) Long schId) { + return accessService.findOwnership(adminGuard.requirePlatformAdmin(user, schId), formUuid); + } + + /** owner 绑定(写):归属校由服务端按表单自己的 sch_id 落,不信入参 */ + @PutMapping("/owner") + public OwnershipView setOwner(BoaUser user, + @RequestParam("formUuid") String formUuid, + @RequestParam(required = false) Long schId, + @RequestBody OwnerRequest body) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + String appKey = body == null ? null : body.getOwnerAppKey(); + requireRegisteredAppKey(appKey, "ownerAppKey"); + return accessService.setOwner(domain, formUuid, appKey, FormAccessPolicy.isSuperAdmin(user)); + } + + /** grant 清单 */ + @GetMapping("/grants") + public List grants(BoaUser user, + @RequestParam("formUuid") String formUuid, + @RequestParam(required = false) Long schId) { + return accessService.listGrants(adminGuard.requirePlatformAdmin(user, schId), formUuid); + } + + /** 授权/重申 */ + @PostMapping("/grants") + public GrantView grant(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody GrantRequest body) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + requireRegisteredAppKey(body == null ? null : body.getConsumerAppKey(), "consumerAppKey"); + return accessService.grant(domain, body, FormAccessPolicy.isSuperAdmin(user)); + } + + /** 撤销(不删行:留审计,grantVersion 进位让消费方认得出旧事件) */ + @PostMapping("/grants/revoke") + public GrantView revoke(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody RevokeRequest body) { + return accessService.revoke(adminGuard.requirePlatformAdmin(user, schId), body, + FormAccessPolicy.isSuperAdmin(user)); + } + + private void requireRegisteredAppKey(String appKey, String field) { + if (appKey == null || appKey.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, field + " 必填(UserBase 注册分配的 appKey)"); + } + if (!callerResolver.isRegisteredAppKey(appKey)) { + throw new BizException(ErrorCode.PARAM_ERROR, + field + "=" + appKey.trim() + " 不是平台已注册的应用身份,先在 UserBase 应用注册里核对 appKey"); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormSyncAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormSyncAdminController.java new file mode 100644 index 0000000..8c72450 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormSyncAdminController.java @@ -0,0 +1,117 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage; +import com.yunhai.formcenter.model.FormSyncModels.ReplayRequest; +import com.yunhai.formcenter.model.FormSyncModels.ResultView; +import com.yunhai.formcenter.model.FormSyncModels.TaskCreateRequest; +import com.yunhai.formcenter.model.FormSyncModels.TaskView; +import com.yunhai.formcenter.service.AnswerReconcileService; +import com.yunhai.formcenter.service.FormAdminGuard; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 对账管理面(TASK_3 改动点 7,{@code /api/form/admin/sync/**})。 + * + *

守卫与 grant 管理同一条:{@code requirePlatformAdmin}(校管理员 ∪ 超管), + * 校级管理员的可见与可操作范围都钉在本校域内(跨域寻址按"任务不存在"回答)。 + * 这里没有任何"批量重发全部答卷"的入口——全量只能建对账任务由 Worker 逐差异修复, + * 单答卷才允许直接重放投递({@code replayAuthorized}),且执行前重新过一遍 ACL。

+ * + *

旧 outbox 日志不等于新答卷:{@code /deliveries/failed} 只是投递侧的失败/死信查看, + * 真正的差异修复走 {@code /tasks},两者不互相证明。

+ */ +@RestController +@RequestMapping("/api/form/admin/sync") +@RequiredArgsConstructor +public class FormSyncAdminController { + + private final FormAdminGuard adminGuard; + private final AnswerReconcileService reconcileService; + + /** 建对账任务:ANSWER / FORM / ALL 三种范围;超限与范围不符落 FAILED 留痕行并回原因 */ + @PostMapping("/tasks") + public List create(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody TaskCreateRequest body) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.createManualTasks(user, domain, body); + } + + /** 任务列表(进度与状态) */ + @GetMapping("/tasks") + public Page list(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestParam(required = false) String consumerAppKey, + @RequestParam(required = false) Long consumerSchoolId, + @RequestParam(required = false) String state, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.taskPage(domain, consumerAppKey, consumerSchoolId, state, page, size); + } + + /** 任务详情(含最近一次回报摘要) */ + @GetMapping("/tasks/{taskId}") + public TaskView detail(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("taskId") String taskId) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.taskDetail(domain, taskId); + } + + /** 逐 attempt 的结果与失败明细(每条都能定位 consumer/task/range/answer) */ + @GetMapping("/tasks/{taskId}/results") + public List results(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("taskId") String taskId) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.results(domain, taskId); + } + + /** 取消任务:剩余投递与修复一律停(幂等,终态行不会被改写) */ + @PostMapping("/tasks/{taskId}/cancel") + public TaskView cancel(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("taskId") String taskId) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.cancel(domain, taskId, user); + } + + /** 单答卷重放(先重新 ACL 再执行;历史 revision 的明文快照不可重建,按稳定码回结论) */ + @PostMapping("/replay") + public DeliveryStateView replay(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody ReplayRequest body) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.replayAnswer(user, domain, + body == null ? null : body.getConsumerAppKey(), + body == null ? null : body.getConsumerSchoolId(), + body == null ? null : body.getAnswerUuid(), + body == null ? null : body.getExpectedRevision(), + body == null ? null : body.getOperationId()); + } + + /** 投递失败/死信分页(TASK_2 failedPage 的转发,不给它另拼一套查询) */ + @GetMapping("/deliveries/failed") + public FailedDeliveryPage failedDeliveries(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestParam String consumerAppKey, + @RequestParam Long consumerSchoolId, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size) { + Long domain = adminGuard.requirePlatformAdmin(user, schId); + return reconcileService.failedDeliveries(domain, consumerAppKey, consumerSchoolId, page, size); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/HostedFillController.java b/src/main/java/com/yunhai/formcenter/controller/api/HostedFillController.java new file mode 100644 index 0000000..218ada4 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/HostedFillController.java @@ -0,0 +1,99 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.formcenter.common.FormRuntimeRules; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.SubmissionContext; +import com.yunhai.formcenter.enums.FormEnums; +import com.yunhai.formcenter.model.AnswerDeliveryModels.HostedFormView; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.service.AnswerReadService; +import com.yunhai.formcenter.service.AnswerService; +import com.yunhai.formcenter.service.FormAccessPolicy; +import com.yunhai.formcenter.service.FormDefService; +import com.yunhai.formcenter.service.FormDictService; +import com.yunhai.formcenter.service.SubmissionContextService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * hosted 填报面(answer-delivery-v2 §6.3,TASK_2 A.3):被业务应用嵌入时的取址与提交。 + * + *

与中心自用填报页的三点不同,都在"钉版"这件事上:

+ *
    + *
  • 按上下文令牌取 schema,坐标是签发时锁定的 {@code formUuid+formVersion}, + * 永不回落到当前发布版——升版窗口里在途的办件必须按当时那一版填, + * 否则收上来的值与办件绑定的坐标就不是同一张表;
  • + *
  • 身份仍由 CAS 取:令牌只说"这次填报属于哪个意图", + * {@code schId/submitterId} 一律会话推导,且与令牌断言的人/校逐字比对(错人错校即拒);
  • + *
  • 首批只允许登录本人:匿名与普通提交仍是 FC 自用的两条既有通道, + * 匿名答卷进不了这条链({@code submitterId} 对不上哨兵 0,见 + * {@code SubmissionContextService#redeem})。
  • + *
+ * + *

提交走 {@code POST /api/form/hosted/submit},请求体与 {@code /api/form/submit} 同形 + * (多带一个 {@code contextToken});管线仍是 {@code AnswerService} 那一条, + * hosted 只是"多一道令牌复核 + 原子关联意图",不开第二份写库实现。

+ */ +@RestController +@RequestMapping("/api/form/hosted") +@RequiredArgsConstructor +public class HostedFillController { + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final SubmissionContextService contextService; + private final AnswerService answerService; + private final FormDefService defService; + private final FormDictService dictService; + + /** + * 上下文钉住版本的 schema 与表单要点(令牌过期/错人/错校都拿不到,按上下文无效拒)。 + */ + @GetMapping("/schema") + public HostedFormView schema(BoaUser user, @RequestParam("contextToken") String contextToken) { + Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,无法打开托管填报页"); + SubmissionContext ctx = contextService.redeem(contextToken, user, schId); + FormDefService.Published pinned = defService.resolvePublishedByUuid(schId, + ctx.getFormUuid(), ctx.getFormVersion()); + Form form = pinned.form(); + HostedFormView view = new HostedFormView(); + view.setFormUuid(form.getUuid()); + view.setAppCode(form.getAppCode()); + view.setCode(form.getCode()); + view.setName(form.getName()); + view.setVersion(pinned.versionRow().getVersion()); + view.setAudience(FormEnums.Audience.namesOf(form.getAudience())); + view.setFillLimit(form.getFillLimit()); + view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline())); + view.setDeadlineReached(FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())); + view.setGroups(AnswerReadService.groupsNode( + dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson()))); + view.setExpiresAt(ctx.getExpiresAt() == null ? null : TS.format(ctx.getExpiresAt())); + view.setEmbedOrigin(ctx.getEmbedOrigin()); + return view; + } + + /** + * hosted 提交(同一件事的两个入口形状,实现只有一条):请求体必须带 {@code contextToken}, + * 由 {@code AnswerService} 在答卷事务内兑换、校验坐标并原子关联意图。 + */ + @PostMapping("/submit") + public AnswerModels.SubmitView submit(BoaUser user, @RequestBody AnswerModels.SubmitRequest body) { + if (body == null || body.getContextToken() == null || body.getContextToken().trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "hosted 提交必须带 contextToken(托管填报入口不接收无上下文的提交)"); + } + return answerService.submit(user, body); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/AnswerDeliveryReceiptController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/AnswerDeliveryReceiptController.java new file mode 100644 index 0000000..df75ded --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/AnswerDeliveryReceiptController.java @@ -0,0 +1,73 @@ +package com.yunhai.formcenter.controller.oapi; + +import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReport; +import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReportView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantSnapshotView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantVerifyRequest; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.service.AnswerDeliveryService; +import com.yunhai.formcenter.service.ConsumerPrincipalResolver; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +/** + * 投递结果回执与授权核验端点(answer-delivery-v2 §5/§7,TASK_2 B.6 + 撤权边界)。 + * + *

两条都是签名 HTTP,与 MQ 确认是两回事:

+ *
    + *
  • {@code POST /oapi/form/answer-delivery/receipt}——消费方业务落库后的结果。 + * broker 的 confirm 只证明总线收下,绝不能代替它(B.4/B.6);{@code consumerAppKey} + * 以验签身份为准,请求体里自报的值不一致即拒;
  • + *
  • {@code POST /oapi/form/answer-delivery/grants/verify}——轻量授权核验:按 formUuid 批量 + * 回 {@code subscribeAllowed + grantVersion},不回查任何答案值文档。消费端带 TTL 缓存它, + * 缓存命中期间撤权的窗口由这条接口收敛;无法确认时消费端必须暂缓写入(v2 §5)。 + * 判据仍然是 TASK_1 的 {@code requireSubscribe},这里不拼第二套 ACL。
  • + *
+ * + *

路径在 {@code /oapi/*} 下,由既有 {@code RequireAppSignFilter} 强制应用签名。

+ */ +@RestController +@RequestMapping("/oapi/form/answer-delivery") +@RequiredArgsConstructor +public class AnswerDeliveryReceiptController { + + private final AnswerDeliveryService deliveryService; + private final ConsumerPrincipalResolver callerResolver; + + /** 消费方应用结果回执(幂等:同 eventId+同结论重复上报返回原结论) */ + @PostMapping("/receipt") + public AppliedReportView receipt(HttpServletRequest request, @RequestBody AppliedReport body) { + ConsumerPrincipal caller = caller(request); + if (body != null && body.getConsumerAppKey() != null + && !body.getConsumerAppKey().trim().equals(caller.getAppKey())) { + // 自报消费方与验签身份不符:这不是"参数不对",是冒充,按不匹配拒(回执本身不做存在性区分) + throw new BizException(ErrorCode.FORBIDDEN, "回执申报的 consumerAppKey 与已验签身份不符"); + } + if (body != null) { + body.setConsumerAppKey(caller.getAppKey()); + } + return deliveryService.recordApplied(body); + } + + /** 批量授权核验(每条事件不回查值文档;有 TTL 缓存的消费端用它判"这封还作不作数") */ + @PostMapping("/grants/verify") + public List grants(HttpServletRequest request, @RequestBody GrantVerifyRequest body) { + return deliveryService.grantSnapshot(caller(request), + body == null ? null : body.getConsumerSchoolId(), + body == null ? null : body.getFormUuids()); + } + + private ConsumerPrincipal caller(HttpServletRequest request) { + Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified)); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/FormSyncTaskController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/FormSyncTaskController.java new file mode 100644 index 0000000..8ce3044 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/FormSyncTaskController.java @@ -0,0 +1,78 @@ +package com.yunhai.formcenter.controller.oapi; + +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.model.FormSyncModels.ManifestPageView; +import com.yunhai.formcenter.model.FormSyncModels.ResultAck; +import com.yunhai.formcenter.model.FormSyncModels.ResultReport; +import com.yunhai.formcenter.model.FormSyncModels.TaskAnswerReply; +import com.yunhai.formcenter.service.AnswerReconcileService; +import com.yunhai.formcenter.service.ConsumerPrincipalResolver; +import com.yunhai.platform.appauth.AppAuthFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; + +/** + * 对账任务的消费端面(TASK_3 改动点 3,全部签名 HTTP)。 + * + *

三条端点成对于 svc-hall 侧的 Worker:清单分页、任务快照答复、结果回报。 + * 与 {@code OapiFormController} 隔离——那边是"按游标增量拉答卷"的数据面, + * 这一边是"某一轮对账的取证与回报",鉴权语境完全不同(这里必须核任务坐标与最新 grant)。

+ * + *

身份唯一来源仍是平台验签后写在 {@code yh.app.auth} 上的 appKey; + * 请求里申报的任何 consumerAppKey 都不采信,冒充任务目标即 {@code 403}。 + * 每次读取都过一遍 {@code AnswerReconcileService} 的核验:任务存在、身份等于目标、 + * 未取消、最新 grant 仍生效且纪元未进位——不因"建任务时有权"永久放行。

+ */ +@RestController +@RequestMapping("/oapi/form-sync/tasks") +@RequiredArgsConstructor +public class FormSyncTaskController { + + private final AnswerReconcileService reconcileService; + private final ConsumerPrincipalResolver callerResolver; + + /** 清单分页:游标=上一页返回的 nextCursor(answerUuid),首次传空;结束只看 completed */ + @GetMapping("/{taskId}/manifest") + public ManifestPageView manifest(HttpServletRequest request, + @PathVariable("taskId") String taskId, + @RequestParam(value = "cursor", required = false) String cursor, + @RequestParam(value = "size", required = false) Integer size) { + return reconcileService.manifestPage(caller(request), taskId, cursor, size); + } + + /** + * 任务快照答复:修复入口的唯一权威来源。 + * + *

{@code expectedRevision} 只在"中心清单里没有这份答卷"时用于把缺席证明钉在消费端扫描到的 + * 那一版上(消费端并发护栏);存在性结论与快照内容都来自中心当前值。

+ */ + @GetMapping("/{taskId}/answers/{answerUuid}") + public TaskAnswerReply answer(HttpServletRequest request, + @PathVariable("taskId") String taskId, + @PathVariable("answerUuid") String answerUuid, + @RequestParam(value = "expectedRevision", required = false) + Long expectedRevision) { + return reconcileService.taskAnswer(caller(request), taskId, answerUuid, expectedRevision); + } + + /** 结果回报:{@code (taskId, attempt)} 幂等,旧 attempt 不倒退新 attempt */ + @PostMapping("/{taskId}/result") + public ResultAck result(HttpServletRequest request, + @PathVariable("taskId") String taskId, + @RequestBody ResultReport body) { + return reconcileService.submitResult(caller(request), taskId, body); + } + + private ConsumerPrincipal caller(HttpServletRequest request) { + Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified)); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java index 51d7f18..5de199d 100644 --- a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java @@ -1,25 +1,37 @@ package com.yunhai.formcenter.controller.oapi; +import com.yunhai.formcenter.model.FormAccessModels.CatalogPage; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; import com.yunhai.formcenter.model.OapiModels; +import com.yunhai.formcenter.service.ConsumerPrincipalResolver; import com.yunhai.formcenter.service.OapiFormService; +import com.yunhai.platform.appauth.AppAuthFilter; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import javax.servlet.http.HttpServletRequest; + /** * 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的 * {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。 * *
- * GET /oapi/form/schema?schId=&code=[&appCode=]      → 现形态:data=内联 options + valueSchema 的机读 schema(契约 §4)
+ * GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize= → v3 目录(已发布 ∩ 可读,先过滤再分页)
+ * GET /oapi/form/schema?schId=&code=[&appCode=]      → 现形态:内联 options + valueSchema 的机读 schema(契约 §4)
  * GET /oapi/form/schema?schId=&uuid=                 → T11 新形态:该表单当前已发布版
  * GET /oapi/form/schema?schId=&uuid=&version=    → T11 新形态:精确版本行(快照核对/重放)
  * GET /oapi/form/answer?schId=&answerId=             → data=钉住版本的 schema 引用 + 值文档(契约 §9)
  * GET /oapi/form/answers?schId=&form_uuid=&游标   → v2 增量拉取(契约 v2 §6.1,T17,见方法注释)
  * 
* + *

签名只给身份,不给权限(v3 §1/§3):本控制器先取平台验签后落在请求属性 + * {@code yh.app.auth} 上的 appKey({@link ConsumerPrincipalResolver}),再交给服务层过 + * {@code FormConsumerAccessService.requireRead}。查询参数 {@code appCode} 与 {@code schId} 一样 + * 只是资源筛选/租户域,任何"自报应用"的写法都不构成授权。

+ * *

uuid 两形态的用途=给 flow-center 在流程定义发布期拉契约快照嵌进 def 版本行 * (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。 * uuid 与 code 同传时 uuid 优先(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口, @@ -38,45 +50,72 @@ import org.springframework.web.bind.annotation.RestController; public class OapiFormController { private final OapiFormService formService; + private final ConsumerPrincipalResolver callerResolver; /** * 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态, * 同传时 uuid 优先(见类头),{@code version} 只在 uuid 形态有意义=精确取版本行。 */ @GetMapping("/schema") - public OapiModels.SchemaView schema(@RequestParam("schId") Long schId, + public OapiModels.SchemaView schema(HttpServletRequest request, + @RequestParam("schId") Long schId, @RequestParam(value = "code", required = false) String code, @RequestParam(value = "appCode", required = false) String appCode, @RequestParam(value = "uuid", required = false) String uuid, @RequestParam(value = "version", required = false) Integer version) { - return formService.schema(schId, appCode, code, uuid, version); + return formService.schema(caller(request), schId, appCode, code, uuid, version); } /** 单条答卷(钉住版本 schema 引用 + 值文档) */ @GetMapping("/answer") - public OapiModels.AnswerView answer(@RequestParam("schId") Long schId, + public OapiModels.AnswerView answer(HttpServletRequest request, + @RequestParam("schId") Long schId, @RequestParam("answerId") String answerId) { - return formService.answer(schId, answerId); + return formService.answer(caller(request), schId, answerId); } /** * v2 增量拉取(契约 §6.1/C9,T17)——事实源,MQ 事件只是降延迟。 * *

-     * GET /oapi/form/answers?schId=&formUuid=&after=answer_uuid[&limit=]        冻结表单:append-only + 墓碑
-     * GET /oapi/form/answers?schId=&formUuid=&updated_since=ISO8601[|answer_uuid]  非冻结:updated 窗口
+     * GET /oapi/form/answers?schId=&form_uuid=&after=answer_uuid[&limit=]        冻结表单:append-only + 墓碑
+     * GET /oapi/form/answers?schId=&form_uuid=&updated_since=ISO8601[|answer_uuid]  非冻结:updated 窗口
      * 
* *

两种游标按表单的冻结状态二分、不接受混用(拒绝文案点明这张表要哪种),{@code limit} 默认 500 * 上限 2000;响应 {@code {rows,nextCursor}},行字段是闭集且不含库内 id(V53)。 - * {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看)。

+ * {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看, + * v3 起还要另过 read 授权)。

*/ @GetMapping("/answers") - public OapiModels.AnswersView answers(@RequestParam("schId") Long schId, + public OapiModels.AnswersView answers(HttpServletRequest request, + @RequestParam("schId") Long schId, @RequestParam("form_uuid") String formUuid, @RequestParam(value = "after", required = false) String after, @RequestParam(value = "updated_since", required = false) String updatedSince, @RequestParam(value = "limit", required = false) Integer limit) { - return formService.answers(schId, formUuid, after, updatedSince, limit); + return formService.answers(caller(request), schId, formUuid, after, updatedSince, limit); + } + + /** + * 表单目录(v3 §3):给消费方的选择器用,返回 {@code {items,total}}。 + * + *

服务端过滤链=已发布 ∩ 未软删 ∩ 本校或平台级 ∩ 调用方可读,过滤完才分页 + * (先分页后过滤会让 {@code total} 与页数随授权抖动)。{@code pageSize} 上限 100。

+ */ + @GetMapping("/forms") + public CatalogPage forms(HttpServletRequest request, + @RequestParam("schId") Long schId, + @RequestParam(value = "appCode", required = false) String appCode, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return formService.catalog(caller(request), schId, appCode, keyword, page, pageSize); + } + + /** 已验签的消费应用身份;属性缺失=验签墙没生效,解析器按未授权拒(fail-closed) */ + private ConsumerPrincipal caller(HttpServletRequest request) { + Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified)); } } diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/SubmissionContextController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/SubmissionContextController.java new file mode 100644 index 0000000..bf40340 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/SubmissionContextController.java @@ -0,0 +1,56 @@ +package com.yunhai.formcenter.controller.oapi; + +import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextIssueRequest; +import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextIssueView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextReceiptView; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.service.ConsumerPrincipalResolver; +import com.yunhai.formcenter.service.SubmissionContextService; +import com.yunhai.platform.appauth.AppAuthFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; + +/** + * 可信提交上下文端点(answer-delivery-v2 §6,TASK_2 A.2/A.4)。 + * + *

独立控制器、独立 DTO:任务书明令不改 TASK_1 的 {@code OapiFormController/OapiModels}, + * 上下文与收据这条链全部落在这里({@link com.yunhai.formcenter.model.AnswerDeliveryModels})。 + * 路径仍在 {@code /oapi/*} 之下,由既有 {@code RequireAppSignFilter} 强制应用签名, + * 调用方身份只取验签后的 {@code yh.app.auth}——请求体里没有任何"自报应用"的字段。

+ * + *
+ * POST /oapi/form/submission-context                 → 签发/复用上下文(同意图不新建第二条业务意图)
+ * GET  /oapi/form/submission-context/{intentId}/receipt → 收据(只向所属消费应用返回)
+ * 
+ */ +@RestController +@RequestMapping("/oapi/form/submission-context") +@RequiredArgsConstructor +public class SubmissionContextController { + + private final SubmissionContextService contextService; + private final ConsumerPrincipalResolver callerResolver; + + @PostMapping + public ContextIssueView issue(HttpServletRequest request, @RequestBody ContextIssueRequest body) { + return contextService.issue(caller(request), body); + } + + @GetMapping("/{intentId}/receipt") + public ContextReceiptView receipt(HttpServletRequest request, @PathVariable("intentId") String intentId) { + return contextService.receipt(caller(request), intentId); + } + + /** 已验签的消费应用身份;属性缺失=验签墙没生效,解析器按未授权拒(fail-closed,同 v3 §1) */ + private ConsumerPrincipal caller(HttpServletRequest request) { + Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified)); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java index 3789560..0496d17 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Answer.java +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -139,4 +139,22 @@ public class Answer extends BaseEntity { /** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */ @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") private Integer isDeleted = 0; + + /** + * 对外快照修订号(v2 投递契约 §2,TASK_2 B.1):单调递增,与 schema 的 + * {@code version}(钉版坐标,{@code updatable=false})是两回事——前者说"这份数据又被改过一次", + * 后者说"它是按哪一版结构填的"。 + * + *

凡是会改变对外答卷快照的路径都在同一事务里 +1 并写不可变 outbox: + * 创建、本人/管理员值编辑、改业务状态(含批量)、软删(含批量)。刻意不把 + * {@code answer_index} 重建、留痕行这类"内部副作用"算作 revision 变更点之外的口径—— + * 判据只有一条:消费方看到的字节变了吗。

+ * + *

并发控制=行级 CAS({@code AnswerRepository#compareAndSetRevision},UPDATE 自身拿行锁, + * 抢不到号即 0 行→回滚)+ outbox 的 {@code uk_delivery_revision} 唯一键兜底。 + * 没上 JPA {@code @Version}:那会让存量分离实体 {@code save} 路径(并发 IT 与批量导入) + * 突然开始抛 OptimisticLockException,属于跨批次的行为变更,不在本任务授权范围内。

+ */ + @Column(name = "answer_revision", nullable = false) + private Long answerRevision = 0L; } diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerDelivery.java b/src/main/java/com/yunhai/formcenter/entity/AnswerDelivery.java new file mode 100644 index 0000000..3ce75fe --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AnswerDelivery.java @@ -0,0 +1,148 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 答卷快照投递行(outbox,answer-delivery-v2 §3,TASK_2 B.1/B.3/B.4)。 + * + *

它是持久性载体,不是发送日志:行必须在答卷同一事务里落库(旧 + * {@code FcEventPublisher} 的 afterCommit 只负责"发",进程在提交与发送之间被杀就永久丢事件—— + * 契约 v2 那时可以用"拉取即事实源"兜底,v2 快照投递不行,因为它要驱动对端的办件)。 + * afterCommit 从此只剩一个作用:唤醒扫描。

+ * + *

唯一键 {@code uk_delivery_revision} 是"同一 revision 不能对应两份不同 hash"的库级形态: + * 两个并发写事务都读到 revision=N-1、各自算出 N 时,只有一个能插入成功,另一个整事务回滚 + * (答卷与 outbox 同行消失),不存在"两条同版不同内容的投递行"。快照内容因此不可重建历史版本, + * 每行自带 {@code snapshot_json}——{@code intent} 关联块只对单个 consumer 可见, + * 各行的载荷本来就不同,不做跨行共享。

+ * + *

{@code state} 只描述投递,不描述业务:{@code BROKER_ACCEPTED} 只代表总线收下 + * (B.4 明令不得写成消费成功),消费方是否真的落库要看 {@code consumer_applied_at} / + * {@code consumer_outcome},那两列只由签名回执端点写。

+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_delivery", + uniqueConstraints = { + @UniqueConstraint(name = "uk_delivery_event", columnNames = "event_id"), + @UniqueConstraint(name = "uk_delivery_revision", + columnNames = {"answer_uuid", "answer_revision", "consumer_app_key", "consumer_school_id"}) + }, + indexes = { + @Index(name = "idx_delivery_scan", columnList = "state, next_retry_at"), + @Index(name = "idx_delivery_consumer", columnList = "consumer_app_key, consumer_school_id, id"), + @Index(name = "idx_delivery_answer", columnList = "answer_uuid, answer_revision")}) +public class AnswerDelivery extends BaseEntity { + + private static final long serialVersionUID = 1L; + + // ===== 投递状态机(state 列)===== + /** 已落库待发送(含 broker 不可用时的退避等待) */ + public static final String STATE_PENDING = "PENDING"; + /** 总线 confirm 收下(≠ 消费方应用成功) */ + public static final String STATE_BROKER_ACCEPTED = "BROKER_ACCEPTED"; + /** 发送异常/mandatory 不可路由,退避后仍会重试 */ + public static final String STATE_FAILED = "FAILED"; + /** 有界重试耗尽,留给人工/对账处置(可查,不再自动重投) */ + public static final String STATE_DEAD = "DEAD"; + /** 发布前复核发现 grant 已失效/进位:不重放旧授权(v2 §5 撤权边界) */ + public static final String STATE_GRANT_REVOKED = "GRANT_REVOKED"; + + /** 消费方结果:本地已落库 */ + public static final String OUTCOME_APPLIED = "APPLIED"; + /** 消费方结果:按 revision 判定无需应用(低版本或同版同 hash 重放) */ + public static final String OUTCOME_IGNORED = "IGNORED"; + /** 消费方结果:同 revision 异 hash,冲突待核验(消费端不覆盖) */ + public static final String OUTCOME_CONFLICT = "CONFLICT"; + + /** 信封事件 id(uuid;broker 侧相关键与消费端幂等键都用它) */ + @Column(name = "event_id", nullable = false, length = 36, updatable = false) + private String eventId; + + @Column(name = "protocol_version", nullable = false, length = 32, updatable = false) + private String protocolVersion; + + /** created / updated / tombstoned */ + @Column(name = "event_type", nullable = false, length = 16, updatable = false) + private String eventType; + + /** 发布方应用身份(服务端配置,不是消费方申报) */ + @Column(name = "source_app_key", length = 64) + private String sourceAppKey; + + /** 投递目标:显式消费应用 appKey(不是资源 appCode,B.3) */ + @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false) + private String consumerAppKey; + + @Column(name = "consumer_school_id", nullable = false, updatable = false) + private Long consumerSchoolId; + + /** 入队时刻的授权纪元(发送前还要再核一次,B.3) */ + @Column(name = "grant_version", nullable = false) + private Integer grantVersion; + + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + @Column(name = "form_version", nullable = false, updatable = false) + private Integer formVersion; + + @Column(name = "answer_uuid", nullable = false, length = 32, updatable = false) + private String answerUuid; + + /** 单调快照修订号(与 answer.answer_revision 同值) */ + @Column(name = "answer_revision", nullable = false, updatable = false) + private Long answerRevision; + + /** 墓碑标记(软删行的快照照常投递,靠它表达"删除") */ + @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint", updatable = false) + private Integer isDeleted = 0; + + /** {@code fc.canon.v1} 下快照(剔除 intent)的 SHA-256 */ + @Column(name = "content_hash", nullable = false, length = 64, updatable = false) + private String contentHash; + + /** 业务发生时刻(快照内已含 create/updateTime,这一列是信封 occurredAt 的落库形态) */ + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + /** 不可变载荷:该 consumer 看到的快照 JSON(含 intent 关联块) */ + @Column(name = "snapshot_json", nullable = false, columnDefinition = "longtext") + private String snapshotJson; + + @Column(name = "state", nullable = false, length = 16) + private String state = STATE_PENDING; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount = 0; + + /** 退避到点才再扫(broker 故障不许热循环) */ + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "broker_accepted_at") + private LocalDateTime brokerAcceptedAt; + + @Column(name = "consumer_applied_at") + private LocalDateTime consumerAppliedAt; + + /** APPLIED / IGNORED / CONFLICT(只有签名回执端点写) */ + @Column(name = "consumer_outcome", length = 16) + private String consumerOutcome; + + /** 最近一次授权重投的操作幂等键({@code replayAuthorized} 写;同键重复调用不再刷新退避) */ + @Column(name = "last_replay_operation_id", length = 64) + private String lastReplayOperationId; + + @Column(name = "last_error", length = 500) + private String lastError; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileManifestRow.java b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileManifestRow.java new file mode 100644 index 0000000..91af75e --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileManifestRow.java @@ -0,0 +1,72 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 任务 manifest 行(TASK_3,answer-reconcile-v1 §3)。 + * + *

本任务里唯一充当"不可变清单"的东西:在一致读事务内物化 + * {@code (answerUuid, revision, contentHash, isDeleted)} 四元摘要(含墓碑), + * 之后所有分页只读这张表,不再回 {@code answer} 表。清单因此不会被物化之后发生的写入改动, + * "这一页空了"与"这个集合到底了"是两件事——后者只能由 {@code manifestComplete} 表达。

+ * + *

键序按 {@code answer_uuid} 升序(32 位小写十六进制),与消费端本地 keyset 扫描同序, + * 两侧能用同一个游标语义锁步推进。哈希取 {@code AnswerSnapshotService} 的 + * {@code fc.canon.v1} 口径,与投递行同一条规范化实现——本类不复制任何规范化逻辑。

+ * + *

{@code formVersion} 取物化时该答卷钉住的 schema 版本(不是表单当前版本,也不从旧清单猜): + * 修复信封的 {@code formVersion} 必须由中心已核验的坐标给出,这一列就是那个坐标的留痕。

+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_reconcile_manifest_row", + uniqueConstraints = @UniqueConstraint(name = "uk_manifest_row", + columnNames = {"task_id", "answer_uuid"}), + indexes = { + @Index(name = "idx_manifest_page", columnList = "task_id, answer_uuid"), + @Index(name = "idx_manifest_seq", columnList = "task_id, seq_no")}) +public class AnswerReconcileManifestRow extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 逻辑引用 answer_reconcile_task.id(不建物理外键:任务行是审计资产,清单要能在其后继续存在) */ + @Column(name = "task_id", nullable = false, updatable = false) + private Long taskId; + + @Column(name = "task_uuid", nullable = false, length = 36, updatable = false) + private String taskUuid; + + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + /** 物化时核验过的发布版本(修复信封坐标) */ + @Column(name = "form_version", nullable = false, updatable = false) + private Integer formVersion; + + @Column(name = "answer_uuid", nullable = false, length = 32, updatable = false) + private String answerUuid; + + /** 物化时刻的对外修订号(不是库内 id、不是页码) */ + @Column(name = "revision", nullable = false, updatable = false) + private Long revision; + + /** fc.canon.v1 摘要(64 位十六进制) */ + @Column(name = "content_hash", nullable = false, length = 64, updatable = false) + private String contentHash; + + /** 墓碑也进清单:缺席与"存在但已删"是两个不同结论 */ + @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint", updatable = false) + private Integer isDeleted; + + /** 物化写入序号(审计用;分页游标是 answer_uuid,不是它) */ + @Column(name = "seq_no", nullable = false, updatable = false) + private Integer seqNo; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileResult.java b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileResult.java new file mode 100644 index 0000000..9429e42 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileResult.java @@ -0,0 +1,122 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 对账结果回报(TASK_3,answer-reconcile-v1 §5):消费端一次执行的可追踪事实。 + * + *

唯一键 {@code (task_id, attempt)} 是幂等锚点:同一 attempt 重复上报直接吸收, + * 比已记录 attempt 更旧的回报只留档、不推进任务状态("不让旧 attempt 回报倒退新 attempt")。 + * 一行=一个 attempt,重派产生的新 attempt 是另一行,历史不被覆盖。

+ * + *

计数都是有上限的摘要:明细只留在 {@code errors_json}(截断到此列长度), + * 答卷的 values 一律不进这里,也不进日志(§8 敏感数据纪律)。

+ * + *

状态语义与任务终态一致:{@code COMPLETED}=两侧集合与摘要在完整扫描下无未决差异, + * {@code PARTIAL}=扫描或修复有未决项(差异仍在,但任务本身跑完了), + * {@code FAILED}/{@code CANCELLED}=执行中断。两者不能混为一谈。

+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_reconcile_result", + uniqueConstraints = @UniqueConstraint(name = "uk_result_attempt", + columnNames = {"task_id", "attempt"}), + indexes = { + @Index(name = "idx_result_task", columnList = "task_id, attempt"), + @Index(name = "idx_result_consumer", columnList = "consumer_app_key, consumer_school_id, id")}) +public class AnswerReconcileResult extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @Column(name = "task_id", nullable = false, updatable = false) + private Long taskId; + + @Column(name = "task_uuid", nullable = false, length = 36, updatable = false) + private String taskUuid; + + @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false) + private String consumerAppKey; + + @Column(name = "consumer_school_id", nullable = false, updatable = false) + private Long consumerSchoolId; + + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + /** 本次回报对应的派发尝试(指令里的 attempt;0=首派) */ + @Column(name = "attempt", nullable = false, updatable = false) + private Integer attempt; + + /** COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报) */ + @Column(name = "state", nullable = false, length = 16, updatable = false) + private String state; + + /** 消费端本地扫描是否整轮完成(多余行判定的前提之一) */ + @Column(name = "local_scan_complete", nullable = false, columnDefinition = "tinyint", updatable = false) + private Integer localScanComplete; + + /** 消费端实际读到的中心清单结束标记(必须与中心一致,否则本轮结论不成立) */ + @Column(name = "manifest_complete", nullable = false, columnDefinition = "tinyint", updatable = false) + private Integer manifestComplete; + + @Column(name = "scanned_rows", nullable = false, updatable = false) + private Integer scannedRows; + + @Column(name = "pages_processed", nullable = false, updatable = false) + private Integer pagesProcessed; + + /** 缺失修复(本地无行,按中心当前值建未关联投影) */ + @Column(name = "missing_repaired", nullable = false, updatable = false) + private Integer missingRepaired; + + /** 陈旧修复(本地 revision 更低) */ + @Column(name = "stale_repaired", nullable = false, updatable = false) + private Integer staleRepaired; + + /** 同 revision 异 hash:记录后按中心当前值修复 */ + @Column(name = "hash_conflicts", nullable = false, updatable = false) + private Integer hashConflicts; + + /** 墓碑修复(同一权威修复原语,保留本地业务扩展) */ + @Column(name = "tombstones_repaired", nullable = false, updatable = false) + private Integer tombstonesRepaired; + + /** 本地 revision 更高:只复核、不降级覆盖 */ + @Column(name = "local_ahead", nullable = false, updatable = false) + private Integer localAhead; + + /** 授权核验不可达/撤权/纪元不等而暂停的条目 */ + @Column(name = "deferred", nullable = false, updatable = false) + private Integer deferred; + + /** 只报告的多余行(含期间新增、createTime 未知、范围不明) */ + @Column(name = "extra_reported", nullable = false, updatable = false) + private Integer extraReported; + + /** 经 FC 签名缺席证明实际退役的行数(未过证明即不增) */ + @Column(name = "extra_retired", nullable = false, updatable = false) + private Integer extraRetired; + + /** 错误条目总数(可能被截断,明细数以 errors_json 里为准) */ + @Column(name = "error_count", nullable = false, updatable = false) + private Integer errorCount; + + @Column(name = "errors_json", nullable = false, columnDefinition = "mediumtext", updatable = false) + private String errorsJson; + + /** 消费端自报的执行起点(断线重启续跑时用于核对检查点) */ + @Column(name = "resumed", nullable = false, columnDefinition = "tinyint", updatable = false) + private Integer resumed; + + @Column(name = "reported_at", nullable = false, updatable = false) + private LocalDateTime reportedAt; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileTask.java b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileTask.java new file mode 100644 index 0000000..a8f2eea --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AnswerReconcileTask.java @@ -0,0 +1,184 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 差异对账任务(TASK_3,answer-reconcile-v1 §2)。 + * + *

它是任务,不是投递行、不是答卷、也不是 {@code answer_op_log}:一行=一个 + * {@code (消费应用, 消费租户域, 表单)} 坐标上的一次对账企图。投递行({@link AnswerDelivery}) + * 描述"这份快照有没有发出去",本行描述"这两边的集合差在哪、修复回报过没有",两者不能互相代替。

+ * + *

任务坐标只到表单,不再宽到"全部表单":全量=按生效订阅逐张表 fission 成多行任务 + * ({@code idempotencyKey} 里的日期+订阅键保证同一天同一订阅只建一次)。这样每一行的 + * manifest 都能用单一 {@code answerUuid} 游标分页,与消费端本地 keyset 扫描同序, + * 两侧可以锁步比较——把"跨表单复合游标"这种一定会写错的东西从设计里删掉。

+ * + *

派发纪律:{@code leaseUntil} 是本应用持有派发权的期限,超期由扫描按 {@code attempt+1} + * 有限退避重派;{@code dispatchedAt} 只说明"对端 2xx 收下了指令", + * 绝不等于对端做完——终态只能由 {@link AnswerReconcileResult} 的回报推进。

+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_reconcile_task", + uniqueConstraints = { + @UniqueConstraint(name = "uk_reconcile_task_uuid", columnNames = "task_uuid"), + @UniqueConstraint(name = "uk_reconcile_task_idem", columnNames = "idempotency_key") + }, + indexes = { + @Index(name = "idx_reconcile_dispatch", columnList = "state, next_dispatch_at"), + @Index(name = "idx_reconcile_consumer", columnList = "consumer_app_key, consumer_school_id, id"), + @Index(name = "idx_reconcile_coord", columnList = "consumer_app_key, consumer_school_id, form_uuid")}) +public class AnswerReconcileTask extends BaseEntity { + + private static final long serialVersionUID = 1L; + + // ===== 任务状态(验收状态闭集,§4)===== + public static final String STATE_QUEUED = "QUEUED"; + public static final String STATE_DISPATCHED = "DISPATCHED"; + public static final String STATE_RUNNING = "RUNNING"; + public static final String STATE_COMPLETED = "COMPLETED"; + public static final String STATE_PARTIAL = "PARTIAL"; + public static final String STATE_FAILED = "FAILED"; + public static final String STATE_CANCELLED = "CANCELLED"; + + /** 终态:不再被派发扫描领取,也不接受进度回退 */ + public static boolean isTerminal(String state) { + return STATE_COMPLETED.equals(state) || STATE_FAILED.equals(state) || STATE_CANCELLED.equals(state); + } + + /** 活跃态:同一坐标上只允许一个(单并发),且这些状态要受租约与取消约束 */ + public static boolean isActive(String state) { + return STATE_QUEUED.equals(state) || STATE_DISPATCHED.equals(state) || STATE_RUNNING.equals(state); + } + + // ===== 触发方式 ===== + public static final String TRIGGER_SCHEDULE = "SCHEDULE"; + public static final String TRIGGER_MANUAL = "MANUAL"; + + // ===== 稳定错误码(每条错误都要能定位 consumer/task/range/answer,§8)===== + public static final String ERR_GRANT_REVOKED = "GRANT_REVOKED"; + public static final String ERR_MANIFEST_INCOMPLETE = "MANIFEST_INCOMPLETE"; + public static final String ERR_RESOURCE_LIMIT = "RESOURCE_LIMIT"; + public static final String ERR_TARGET_NOT_REGISTERED = "TARGET_NOT_REGISTERED"; + public static final String ERR_DISPATCH_FAILED = "DISPATCH_FAILED"; + public static final String ERR_LEASE_EXPIRED = "LEASE_EXPIRED"; + public static final String ERR_CANCELLED = "CANCELLED"; + public static final String ERR_TASK_NOT_FOUND = "TASK_NOT_FOUND"; + public static final String ERR_SCOPE_MISMATCH = "SCOPE_MISMATCH"; + + /** 任务对外身份(服务端 UUID,≤36 字符;URL 与指令里都用它,不用库内 id) */ + @Column(name = "task_uuid", nullable = false, length = 36, updatable = false) + private String taskUuid; + + @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false) + private String consumerAppKey; + + /** 消费租户域:与投递行同口径,也就是答卷的归属校(§1 坐标定义) */ + @Column(name = "consumer_school_id", nullable = false, updatable = false) + private Long consumerSchoolId; + + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + /** 物化时核验过的发布版本;缺席答复与修复信封的 formVersion 都取这一个值 */ + @Column(name = "form_version", nullable = false) + private Integer formVersion; + + /** SCHEDULE / MANUAL(列名避开 MySQL 保留字 TRIGGER) */ + @Column(name = "trigger_type", nullable = false, length = 16, updatable = false) + private String triggerType; + + @Column(name = "operator", length = 64, updatable = false) + private String operator; + + /** 日期 + 订阅 + 范围唯一键:调度重复跑不出第二行任务 */ + @Column(name = "idempotency_key", nullable = false, length = 128, updatable = false) + private String idempotencyKey; + + @Column(name = "state", nullable = false, length = 16) + private String state = STATE_QUEUED; + + /** manifest 物化状态:派发只在 COMPLETE 后进行 */ + @Column(name = "manifest_state", nullable = false, length = 16) + private String manifestState = MANIFEST_PENDING; + + public static final String MANIFEST_PENDING = "PENDING"; + public static final String MANIFEST_MATERIALIZING = "MATERIALIZING"; + public static final String MANIFEST_COMPLETE = "COMPLETE"; + public static final String MANIFEST_FAILED = "FAILED"; + + /** 结束标记:只有它为 1,"清单里没有"才可能是权威缺席 */ + @Column(name = "manifest_complete", nullable = false, columnDefinition = "tinyint") + private Integer manifestComplete = 0; + + @Column(name = "total_rows", nullable = false) + private Integer totalRows = 0; + + @Column(name = "total_pages", nullable = false) + private Integer totalPages = 0; + + @Column(name = "materialized_rows", nullable = false) + private Integer materializedRows = 0; + + @Column(name = "materialized_pages", nullable = false) + private Integer materializedPages = 0; + + /** 物化检查点:已写到的最后一个 answerUuid(进程重启从这里续) */ + @Column(name = "checkpoint_cursor", length = 32) + private String checkpointCursor; + + /** + * 任务起点:一致读事务的开始时刻。消费端据此把"本地 createTime 晚于此刻"的行 + * 判为期间新增——这类行永远不许被当多余行退役。 + */ + @Column(name = "window_start", nullable = false, updatable = false) + private LocalDateTime windowStart; + + /** manifest 物化完成时刻(一致读的终点,给运维看窗口长度) */ + @Column(name = "window_end") + private LocalDateTime windowEnd; + + /** 物化时刻的授权纪元;每轮读取再核一次,进位即视为撤权 */ + @Column(name = "grant_version", nullable = false) + private Integer grantVersion = 1; + + /** 派发尝试次数:首次为 0,租约超期或派发失败后 +1 */ + @Column(name = "attempt", nullable = false) + private Integer attempt = 0; + + @Column(name = "lease_until") + private LocalDateTime leaseUntil; + + @Column(name = "next_dispatch_at") + private LocalDateTime nextDispatchAt; + + @Column(name = "dispatched_at") + private LocalDateTime dispatchedAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + /** 取消请求:管理面置 1 后,后续清单/快照读取一律以取消口径答复,消费端据此停止剩余修复 */ + @Column(name = "cancel_requested", nullable = false, columnDefinition = "tinyint") + private Integer cancelRequested = 0; + + @Column(name = "error_code", length = 32) + private String errorCode; + + @Column(name = "error_detail", length = 500) + private String errorDetail; + + public boolean isCancelled() { + return Integer.valueOf(1).equals(cancelRequested) || STATE_CANCELLED.equals(state); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/FormConsumerGrant.java b/src/main/java/com/yunhai/formcenter/entity/FormConsumerGrant.java new file mode 100644 index 0000000..ed27e80 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/FormConsumerGrant.java @@ -0,0 +1,65 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 消费应用对某张表单的显式授权(消费应用授权 v3 §2,TASK_1)。 + * + *

三元唯一:{@code (form_uuid, consumer_school_id, consumer_app_key)}——授权是 + * "这张表允许这个租户的这个应用读/订阅",租户与应用缺一不可。只按 appKey 授权会丢掉租户维度, + * 而"读答卷仍以该校行集限定"(v3 §2)正是靠 {@code consumer_school_id} 落型的: + * 平台级表单({@code form.sch_id=0})对某校授予后,答卷行集依旧按那一校限定。

+ * + *

默认拒绝:读与订阅是分开的两把开关,都没有隐式全集;owner 应用天然有读权, + * 但订阅同样要显式开一行(读/写分权是这一版的红线)。撤销不删行,改 {@code enabled=0} + * 并让 {@code grantVersion} 进位——留着行才答得出"什么时候撤的权"。

+ * + *

{@code grantVersion} 是给 TASK_3 事件面用的:订阅者必须带回版本号,消费方才能识别 + * "这条推送用的授权已经作废",不允许那边再从库里自己拼一套授权判定。

+ */ +@Getter +@Setter +@Entity +@Table(name = "form_consumer_grant", + uniqueConstraints = @UniqueConstraint(name = "uk_grant", + columnNames = {"form_uuid", "consumer_school_id", "consumer_app_key"}), + indexes = @Index(name = "idx_grant_consumer", columnList = "consumer_app_key, consumer_school_id, enabled")) +public class FormConsumerGrant extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 被授权的表单(全局 uuid) */ + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + /** 消费租户:授权生效的学校域,也是读答卷时的行集限定域 */ + @Column(name = "consumer_school_id", nullable = false) + private Long consumerSchoolId; + + /** 消费应用的平台身份(appKey,非 app_code) */ + @Column(name = "consumer_app_key", nullable = false, length = 64) + private String consumerAppKey; + + /** 读授权(目录/schema/answer/answers):1=允许 */ + @Column(name = "read_allowed", nullable = false, columnDefinition = "tinyint") + private Integer readAllowed = 0; + + /** 订阅授权(答卷变更事件投递):1=允许 */ + @Column(name = "subscribe_allowed", nullable = false, columnDefinition = "tinyint") + private Integer subscribeAllowed = 0; + + /** 生效开关:0=已撤销(保留行与 grant_version 供审计,撤销后后续读取/发布检查即失败) */ + @Column(name = "enabled", nullable = false, columnDefinition = "tinyint") + private Integer enabled = 1; + + /** 授权版本号:每次撤销/变更进位,订阅投递与消费方幂等判定都带它 */ + @Column(name = "grant_version", nullable = false) + private Integer grantVersion = 1; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/FormOwnership.java b/src/main/java/com/yunhai/formcenter/entity/FormOwnership.java new file mode 100644 index 0000000..f1d4a00 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/FormOwnership.java @@ -0,0 +1,49 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 表单的资源拥有者绑定(消费应用授权 v3 §2,TASK_1)。 + * + *

它只回答一件事:这张表单(按全局 uuid)是哪个平台应用(appKey)的资产。 + * 拥有者与消费租户是两件事——{@code owner_app_key} 是资产归属,"别的应用能不能读它"一律走 + * {@link FormConsumerGrant},所以 owner 与 appCode 之间没有任何推导关系: + * 客户端自报的 {@code appCode} 只能筛资源,永远不授予拥有权(v3 §1)。

+ * + *

owner 由谁写:只有管理面(校管理员/超管,见 {@code FormAccessController})能设置, + * 且写入前用平台密钥注册表核验过 appKey 确实在 UserBase 注册存在;签名应用不能自报 owner。 + * 缺归属行=没有拥有者,除显式 grant 外一律拒(fail-closed,默认拒绝)。

+ * + *

{@code form_school_id} 存的是绑定时表单自己的 {@code sch_id}(0=平台级), + * 作为"这条绑定属于哪个资源域"的留痕:管理面的域判定用它复核,防止日后表单换校 + * 让一条跨校绑定看起来仍然合法。

+ */ +@Getter +@Setter +@Entity +@Table(name = "form_ownership", + uniqueConstraints = @UniqueConstraint(name = "uk_ownership_form", columnNames = "form_uuid"), + indexes = @Index(name = "idx_ownership_owner", columnList = "owner_app_key, form_school_id")) +public class FormOwnership extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 资源坐标:表单全局 uuid(契约 §1:不可枚举、跨版本不变);一表一绑定 */ + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + /** 绑定时表单的归属校;0=平台级表单 */ + @Column(name = "form_school_id", nullable = false) + private Long formSchoolId; + + /** 拥有者应用的平台身份(UserBase 注册分配的 appKey,不是 app_code) */ + @Column(name = "owner_app_key", nullable = false, length = 64) + private String ownerAppKey; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/SubmissionContext.java b/src/main/java/com/yunhai/formcenter/entity/SubmissionContext.java new file mode 100644 index 0000000..9c966d8 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/SubmissionContext.java @@ -0,0 +1,101 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 可信提交上下文(answer-delivery-v2 §6,TASK_2 A.2/A.3)。 + * + *

它解决的是"答卷到底是不是这次业务意图产生的"这一个事实:办事大厅先建意图、 + * 再让被嵌页填报,中心必须能把答卷钉回那次意图,而不是相信浏览器带来的 bizId/answerId。 + * 三条形状因此都是必需的:

+ *
    + *
  • 只存 token 摘要:明文 {@code contextToken} 只在签发响应里出现一次; + * 库被读也不能拿它去兑换别人的填报(防"拖库即冒充");
  • + *
  • 唯一键 (consumer_app_key, intent_id):同意图重试返回同一有效上下文, + * 结构上不允许出现"第二个业务意图"(A.2);
  • + *
  • 唯一键 answer_uuid:一份答卷只能原子关联到一个意图, + * "同 token 同幂等键返回原答卷、不同内容重用拒绝"在库级就有兜底(A.3)。
  • + *
+ * + *

{@code embed_origin}/{@code callback_url} 由服务端配置/注册信息填写(签发时落定), + * 请求体里没有这两个字段——浏览器给的任何 URL 都不采信(A.2 末句)。 + * {@code submitter_id} 是消费方服务端断言的填报人,中心在 redeem 时与 CAS 会话逐字复核, + * 对不上就是 {@code FORBIDDEN}(token 错人)。

+ */ +@Getter +@Setter +@Entity +@Table(name = "submission_context", + uniqueConstraints = { + @UniqueConstraint(name = "uk_ctx_token_digest", columnNames = "token_digest"), + @UniqueConstraint(name = "uk_ctx_intent", columnNames = {"consumer_app_key", "intent_id"}), + @UniqueConstraint(name = "uk_ctx_answer", columnNames = "answer_uuid") + }, + indexes = {@Index(name = "idx_ctx_form", columnList = "form_uuid, form_version")}) +public class SubmissionContext extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 消费应用 appKey(验签后的 yh.app.auth,不是请求体里的 appCode) */ + @Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false) + private String consumerAppKey; + + /** 消费租户域(授权与行集限定都用它,v3 §2 的两个维度之一) */ + @Column(name = "consumer_school_id", nullable = false, updatable = false) + private Long consumerSchoolId; + + /** 消费方业务意图键(服务端生成,≤64) */ + @Column(name = "intent_id", nullable = false, length = 64, updatable = false) + private String intentId; + + /** 钉定的表单坐标(成对,签发时即按 v3 §6 校验过版本行存在) */ + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + @Column(name = "form_version", nullable = false, updatable = false) + private Integer formVersion; + + /** 解析出来的资源坐标(hosted 页提交时按它寻址,不再走 code 解析) */ + @Column(name = "app_code", nullable = false, length = 64, updatable = false) + private String appCode; + + @Column(name = "code", nullable = false, length = 64, updatable = false) + private String code; + + /** 消费方断言的填报人(hosted 首批=登录本人;redeem 时与会话逐字比对) */ + @Column(name = "submitter_id", nullable = false, updatable = false) + private Integer submitterId; + + /** contextToken 的 SHA-256(明文不入库) */ + @Column(name = "token_digest", nullable = false, length = 64, updatable = false) + private String tokenDigest; + + /** 签发时锁定的可嵌入父页面 origin(精确 scheme+host+port) */ + @Column(name = "embed_origin", nullable = false, length = 200) + private String embedOrigin; + + /** 签发时锁定的消费方确认入口(服务端配置,非浏览器传入) */ + @Column(name = "callback_url", nullable = false, length = 500) + private String callbackUrl; + + @Column(name = "expires_at", nullable = false, updatable = false) + private LocalDateTime expiresAt; + + /** 答卷原子关联(提交事务内写;NULL=尚未填报。uk_ctx_answer 允许多个 NULL) */ + @Column(name = "answer_uuid", length = 32) + private String answerUuid; + + @Column(name = "linked_revision") + private Long linkedRevision; + + @Column(name = "linked_at") + private LocalDateTime linkedAt; +} diff --git a/src/main/java/com/yunhai/formcenter/model/AnswerDeliveryModels.java b/src/main/java/com/yunhai/formcenter/model/AnswerDeliveryModels.java new file mode 100644 index 0000000..2ff8c83 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/AnswerDeliveryModels.java @@ -0,0 +1,301 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 答卷投递 v2 的出入参(answer-delivery-v2 契约,TASK_2)。 + * + *

三条形状纪律:

+ *
    + *
  • 信封字段闭集:{@link SnapshotEnvelope} 逐字段标 {@link JsonProperty}—— + * 契约里写的是 camelCase,靠命名策略碰巧对上是脆弱的,多一个字段就该被当成违约(同 v1 §6.2 的 + * {@code FcEventPublisherTest} 立场);
  • + *
  • 身份不进消费方请求:{@link ContextIssueRequest} 里没有 appKey/consumer 身份位, + * 调用方身份只取验签后的 {@code yh.app.auth}(v3 §1,TASK_1 已定的口径); + * 浏览器侧更不会出现在这条链上(这是服务端到服务端的签名调用);
  • + *
  • 回执与投递状态分开:{@code brokerAccepted}(总线收下)与 + * {@code consumerApplied}(消费方业务落库并回执)是两个时刻、两组列, + * 谁也不能替谁作证(B.4/B.6)。
  • + *
+ */ +public final class AnswerDeliveryModels { + + /** v2 协议号(同时锁定 {@code AnswerCanonicalizer.fc.canon.v1} 的哈希口径) */ + public static final String PROTOCOL_VERSION = "fc.answer.snapshot.v2"; + + /** v2 专用 exchange(与 v1 广播 exchange 物理分开,见配置与部署契约) */ + public static final String EXCHANGE = "fc.answer.delivery.v2"; + + /** 事件类型闭集:值/状态变更都归 {@code updated},墓碑单列 */ + public static final String EVENT_CREATED = "created"; + public static final String EVENT_UPDATED = "updated"; + public static final String EVENT_TOMBSTONED = "tombstoned"; + + private AnswerDeliveryModels() { + } + + /** 消费端扩展字段的类型标记(消费方本地列,永不参与快照与哈希) */ + @Getter + @Setter + public static class Snapshot { + private Long schId; + private String appCode; + private String code; + private Integer submitterId; + private Integer subjectId; + private String status; + private JsonNode values; + private String answerNo; + /** ISO-8601 本地日期时间({@code yyyy-MM-ddTHH:mm:ss}),固定形态、不带时区偏移 */ + private String createTime; + private String updateTime; + /** 仅对本 consumer 可见的意图关联(不参与 contentHash,见 fc.canon.v1 规则⑥) */ + private JsonNode intent; + } + + /** v2 信封({@code fc.answer.delivery.v2} 的消息体) */ + @Getter + @Setter + public static class SnapshotEnvelope { + + @JsonProperty("protocolVersion") + private String protocolVersion = PROTOCOL_VERSION; + + @JsonProperty("eventId") + private String eventId; + + @JsonProperty("eventType") + private String eventType; + + /** 发布方(本表单中心)的应用身份,由服务端配置给,不接受消费方申报 */ + @JsonProperty("sourceAppKey") + private String sourceAppKey; + + @JsonProperty("consumerAppKey") + private String consumerAppKey; + + @JsonProperty("consumerSchoolId") + private Long consumerSchoolId; + + /** 投递行的授权纪元:撤权/能力变更即进位,消费端按它判"这封还作不作数" */ + @JsonProperty("grantVersion") + private Integer grantVersion; + + @JsonProperty("formUuid") + private String formUuid; + + @JsonProperty("formVersion") + private Integer formVersion; + + @JsonProperty("answerUuid") + private String answerUuid; + + @JsonProperty("answerRevision") + private Long answerRevision; + + @JsonProperty("isDeleted") + private Boolean isDeleted; + + /** {@code fc.canon.v1} 下快照(剔除 intent)的 SHA-256 十六进制 */ + @JsonProperty("contentHash") + private String contentHash; + + @JsonProperty("occurredAt") + private String occurredAt; + + @JsonProperty("snapshot") + private Snapshot snapshot; + } + + // ==================== 提交上下文(A.2/A.4) ==================== + + /** 消费方申请可信提交上下文({@code POST /oapi/form/submission-context}) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ContextIssueRequest { + /** 消费方业务意图键(服务端生成,浏览器不可注入);同 (appKey,intentId) 重试返回同一上下文 */ + private String intentId; + private Long consumerSchoolId; + /** 钉定的表单坐标(必须是服务器校验过的成对值,单传或"看起来对"都拒) */ + private String formUuid; + private Integer formVersion; + /** 上下文绑定的填报人(hosted 首批=登录本人;FC 在 redeem 时逐字复核会话用户) */ + private Integer submitterId; + } + + /** 上下文签发结果(token 明文只在这一条响应里出现一次,库里只存摘要) */ + @Getter + @Setter + public static class ContextIssueView { + private String intentId; + private String contextToken; + /** 消费方页面的精确 origin(服务端注册信息,浏览器传的一律不用) */ + private String embedOrigin; + /** 消费方确认入口的绝对 URL(同上) */ + private String callbackUrl; + private String formUuid; + private Integer formVersion; + private String appCode; + private String code; + private String formName; + private String expiresAt; + /** 同一意图重试命中原上下文=true(不新建第二个业务意图) */ + private Boolean reused; + } + + /** 上下文收据({@code GET /oapi/form/submission-context/{intentId}/receipt}) */ + @Getter + @Setter + public static class ContextReceiptView { + /** PENDING=还没答卷;SUBMITTED=已原子关联答卷 */ + private String status; + private String intentId; + private String consumerAppKey; + private Long consumerSchoolId; + private Integer submitterId; + private String formUuid; + private Integer formVersion; + private String answerUuid; + private Long answerRevision; + private String contentHash; + private String submittedAt; + private Boolean expired; + /** 收据带快照:可信建件不依赖"MQ 恰好已投递"(本地无 broker 时这条是唯一通路) */ + private Snapshot snapshot; + } + + // ==================== 投递回执与授权核验(B.6) ==================== + + /** 消费方应用结果回执({@code POST /oapi/form/answer-delivery/receipt}) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AppliedReport { + private String eventId; + private String consumerAppKey; + private Long consumerSchoolId; + private String answerUuid; + private Long answerRevision; + /** APPLIED=本地已落库;IGNORED=按 revision 判定无需应用;CONFLICT=同版异 hash */ + private String outcome; + /** 消费端复算的 hash(与信封一致才可能判 APPLIED) */ + private String contentHash; + /** 消费端本地引用(办件号之类),只作审计,不参与幂等判据 */ + private String localRef; + } + + /** 回执处理结果(幂等:重复上报同一 eventId+consumer 返回原结论) */ + @Getter + @Setter + public static class AppliedReportView { + private String eventId; + private String state; + private Boolean duplicate; + private String brokerAcceptedAt; + private String consumerAppliedAt; + } + + /** 授权快照核验({@code POST /oapi/form/answer-delivery/grants/verify},批量、不回查值文档) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GrantVerifyRequest { + private Long consumerSchoolId; + private List formUuids = new ArrayList<>(); + } + + /** 单张表单的授权快照(消费端带 TTL 缓存它;查不到就是"当前不生效") */ + @Getter + @Setter + public static class GrantSnapshotView { + private String formUuid; + private Long consumerSchoolId; + private String consumerAppKey; + private Boolean subscribeAllowed; + private Integer grantVersion; + /** 核验时点(ISO),消费端据此算缓存有效期 */ + private String verifiedAt; + } + + // ==================== 投递状态与 TASK_3 扩展点出参 ==================== + + /** 快照视图({@code AnswerSnapshotService.currentAuthorized}) */ + @Getter + @Setter + public static class SnapshotView { + private String answerUuid; + private Long answerRevision; + private String contentHash; + private Boolean isDeleted; + private Snapshot snapshot; + } + + /** 投递状态视图({@code replayAuthorized} 的返回;稳定状态码见枚举注释) */ + @Getter + @Setter + public static class DeliveryStateView { + /** + * 稳定状态码(TASK_3 只调用不编辑,故码值不随文案变): + * {@code QUEUED}=已重新入队;{@code ALREADY_DELIVERED}=行已 brokerAccepted 且无异常; + * {@code GRANT_REVOKED}=当前授权不生效,不重放旧 grant; + * {@code REVISION_MISMATCH}=期望 revision 与库内当前值不符(历史版本内容不可重建)。 + */ + private String state; + private String eventId; + private Long answerRevision; + private String message; + } + + /** 失败投递分页({@code failedPage}) */ + @Getter + @Setter + public static class FailedDeliveryPage { + private long total; + private List rows = new ArrayList<>(); + } + + /** 单条失败投递(含死信形态,运维可查) */ + @Getter + @Setter + public static class FailedDeliveryView { + private String eventId; + private String answerUuid; + private Long answerRevision; + private String consumerAppKey; + private Long consumerSchoolId; + private String state; + private Integer retryCount; + private String lastError; + private String nextRetryAt; + private String brokerAcceptedAt; + private String consumerAppliedAt; + } + + /** hosted 填报页元数据({@code GET /api/form/hosted/schema}) */ + @Getter + @Setter + public static class HostedFormView { + private String formUuid; + private String appCode; + private String code; + private String name; + /** 上下文钉住的确切版本(hosted 永不回落到当前发布版) */ + private Integer version; + private List audience; + private Integer fillLimit; + private String deadline; + private Boolean deadlineReached; + private Object groups; + private String expiresAt; + /** 消费方 origin:填报页 postMessage 的唯一允许目标(精确到 scheme+host+port) */ + private String embedOrigin; + } +} diff --git a/src/main/java/com/yunhai/formcenter/model/AnswerModels.java b/src/main/java/com/yunhai/formcenter/model/AnswerModels.java index 0ac79a5..0f1ca70 100644 --- a/src/main/java/com/yunhai/formcenter/model/AnswerModels.java +++ b/src/main/java/com/yunhai/formcenter/model/AnswerModels.java @@ -37,6 +37,14 @@ public final class AnswerModels { private String idempotencyKey; /** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */ private Integer subjectId; + /** + * 可选:可信提交上下文令牌(v2 契约 §6.3,TASK_2 A.3)。带它的提交就是 hosted 链路—— + * 中心在答卷事务内兑换令牌、按令牌锁定的 {@code formUuid+formVersion} 钉版、 + * 复核会话用户/学校,并把答卷原子关联回消费方的业务意图。 + * 它不是身份载体:{@code schId/submitterId} 仍只由会话推导(V17 不变), + * 令牌里断言的人与会话不符即拒;也没有 bizId 位——浏览器传不进办件坐标。 + */ + private String contextToken; /** 值文档 {@code {key:value}},形状见契约 §3 */ private JsonNode values; } diff --git a/src/main/java/com/yunhai/formcenter/model/FormAccessModels.java b/src/main/java/com/yunhai/formcenter/model/FormAccessModels.java new file mode 100644 index 0000000..ea0fa8f --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/FormAccessModels.java @@ -0,0 +1,127 @@ +package com.yunhai.formcenter.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 消费应用授权与目录(v3,TASK_1)的模型。 + * + *

三条不外露的线:库内 {@code id}(契约 §1/V5 同口径,寻址一律 uuid + 三元键)、 + * 任何 appSecret(授权判定只用平台密钥注册表验"这个 appKey 存不存在",绝不回显密钥)、 + * 归属差别信息(跨校/缺归属/不存在同一句拒绝,见 {@code FormConsumerAccessService})。

+ */ +public final class FormAccessModels { + + private FormAccessModels() { + } + + /** + * 已验签的消费应用身份。唯一字段是 {@code appKey}——它由平台 {@code AppAuthFilter} 验签通过后 + * 写在请求属性 {@code yh.app.auth} 上,本应用只是转读,没有任何"客户端自报身份"的入口。 + * + *

{@code equals} 是给单测的透传断言用的(controller 层"只传不改"这件事要能按值比对), + * 不是给业务判权用的。

+ */ + @Getter + @Setter + @lombok.EqualsAndHashCode + public static class ConsumerPrincipal { + private String appKey; + + public ConsumerPrincipal() { + } + + public ConsumerPrincipal(String appKey) { + this.appKey = appKey; + } + } + + /** 订阅授权(v3 §7):{@code grantVersion} 必须随投递下发,消费方据此识别"这份授权已作废" */ + @Getter + @Setter + public static class SubscriptionGrant { + private String formUuid; + private Long consumerSchoolId; + private String consumerAppKey; + private boolean readAllowed; + private boolean subscribeAllowed; + private int grantVersion; + } + + // ==================== 目录(GET /oapi/form/forms) ==================== + + /** 目录行:够消费方选择器用的最小坐标集,选中后按 {@code formUuid}+{@code version} 存绑定 */ + @Getter + @Setter + public static class CatalogItem { + private String formUuid; + private String code; + private String name; + private String appCode; + /** 当前已发布版本号(目录只出已发布行,所以它必然非空) */ + private Integer version; + /** 面向人群(契约 v2 C5 形态:字符串数组) */ + private List audience; + } + + /** 目录分页载荷:{@code {items,total}},{@code total}=过滤后的可读集大小(不是库内行数) */ + @Getter + @Setter + public static class CatalogPage { + private List items; + private long total; + } + + // ==================== 管理面 ==================== + + /** owner 绑定视图 */ + @Getter + @Setter + public static class OwnershipView { + private String formUuid; + private Long formSchoolId; + private String ownerAppKey; + } + + /** owner 绑定请求:只有 appKey 一件——归属校由服务端按表单自己的 {@code sch_id} 落,不信入参 */ + @Getter + @Setter + public static class OwnerRequest { + private String ownerAppKey; + } + + /** grant 视图(含已撤销行) */ + @Getter + @Setter + public static class GrantView { + private String formUuid; + private Long consumerSchoolId; + private String consumerAppKey; + private boolean readAllowed; + private boolean subscribeAllowed; + private boolean enabled; + private int grantVersion; + } + + /** grant 授权请求(三元键 + 两把开关;不传开关=只给读,默认拒绝) */ + @Getter + @Setter + public static class GrantRequest { + private String formUuid; + private Long consumerSchoolId; + private String consumerAppKey; + private Boolean readAllowed; + private Boolean subscribeAllowed; + } + + /** grant 撤销请求 */ + @Getter + @Setter + public static class RevokeRequest { + private String formUuid; + private Long consumerSchoolId; + private String consumerAppKey; + } +} diff --git a/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java b/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java new file mode 100644 index 0000000..8d281db --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/FormSyncModels.java @@ -0,0 +1,306 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 差异对账协议载荷(TASK_3,answer-reconcile-v1 §6)。 + * + *

独立 DTO:不往 {@code OapiModels}(TASK_1)与 {@code AnswerDeliveryModels}(TASK_2)里塞字段, + * 那两个文件的形状由各自任务负责。唯一的跨任务共享类型是 + * {@link AnswerDeliveryModels.SnapshotEnvelope}/{@link AnswerDeliveryModels.Snapshot}—— + * 修复信封必须与投递信封同一条规范化口径,复制一份就给了哈希分叉机会。

+ * + *

{@code values} 一律只出现在 {@code Snapshot} 里、由签名任务端点按授权下发; + * 本文件的任何回报结构都不带值文档(§8 敏感数据纪律:错误明细里也不许出现)。

+ */ +public final class FormSyncModels { + + /** 协议号:锁死"清单游标=answerUuid、终态闭集、缺席需证明"这三条本轮约定 */ + public static final String PROTOCOL = "fc.form-sync.v1"; + + /** 任务快照答复的存在性结论 */ + public static final String PRESENCE_PRESENT = "PRESENT"; + public static final String PRESENCE_ABSENT = "ABSENT"; + public static final String PRESENCE_UNKNOWN = "UNKNOWN"; + + private FormSyncModels() { + } + + // ==================== FC → SH:派发指令 ==================== + + /** + * 派发指令({@code POST /oapi/form-sync/reconcile} 的请求体)。 + * + *

只带任务坐标与清单规模,不带任何答卷数据;地址来自服务端登记的后台服务地址, + * 浏览器提供的 callbackUrl 在这里没有任何对应字段。

+ */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReconcileInstruction { + private String protocol = PROTOCOL; + /** 任务对外身份(task_uuid) */ + private String taskId; + /** 本次派发尝试(结果幂等键的另一半) */ + private Integer attempt; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private Integer formVersion; + /** SCHEDULE / MANUAL */ + private String triggerType; + /** 任务起点(yyyy-MM-dd'T'HH:mm:ss):本地 createTime 晚于此刻的行不得判为多余行 */ + private String windowStart; + /** 清单物化完成时刻 */ + private String materializedAt; + private Integer totalRows; + private Integer totalPages; + /** 建议页大小:消费端本地扫描与中心清单分页都用它,锁步比较才对得上 */ + private Integer pageSize; + private String issuedAt; + } + + /** 派发应答(对端 2xx 只说明指令被收下并持久化,不代表执行结果) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstructionAck { + private String taskId; + private Integer attempt; + private Boolean accepted; + /** 同 taskId 重复指令:已接收过,复用原任务与检查点 */ + private Boolean duplicate; + private String state; + private String message; + } + + // ==================== SH → FC:清单 / 快照 / 缺席 / 结果 ==================== + + /** 清单行(四元摘要 + 中心已核验的表单坐标) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ManifestRowView { + private String answerUuid; + private Long revision; + private String contentHash; + private Boolean isDeleted; + private String formUuid; + private Integer formVersion; + } + + /** + * 清单分页:{@code completed=true} 才是集合结束,空页不是。 + * 游标是最后一个 answerUuid,与消费端本地扫描同序、同语义。 + */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ManifestPageView { + private String taskId; + private List rows = new ArrayList<>(); + private String nextCursor; + private Boolean completed; + private Integer totalRows; + private Integer totalPages; + /** 任务当前状态(CANCELLED 时消费端须停止剩余修复) */ + private String taskState; + } + + /** + * 任务快照答复:修复入口唯一的权威来源。 + * + *

{@code presence=PRESENT} 时带 {@code envelope}(服务端按已核验坐标构造, + * formUuid/formVersion 来自任务行与答卷行,不从旧清单猜); + * {@code ABSENT} 时带 {@code absenceProof};{@code UNKNOWN}(清单未完整、期间新增、 + * 范围改变、撤权)时两者都不给,只给原因——消费端据此只能报告差异,不能破坏本地数据。

+ */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TaskAnswerReply { + private String taskId; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private Integer formVersion; + private String answerUuid; + private String presence; + private Integer grantVersion; + private AnswerDeliveryModels.SnapshotEnvelope envelope; + private AbsenceProof absenceProof; + /** 无法给出权威结论时的稳定原因码(MANIFEST_INCOMPLETE / GRANT_REVOKED / CANCELLED / …) */ + private String reasonCode; + private String reason; + private String verifiedAt; + } + + /** + * 权威缺席证明(只在完整中心清单 + 新鲜授权核验都通过后签发)。 + * {@code proofId} 是可追踪引用,不是身份凭证;{@code expectedRevision} 必填—— + * 版本对不上本地行的退役请求会被消费端拒绝。 + */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AbsenceProof { + private String proofId; + private String taskId; + private String formUuid; + private Long schId; + private String answerUuid; + private Long expectedRevision; + private Integer grantVersion; + private String issuedAt; + } + + /** 结果回报({@code POST /oapi/form-sync/tasks/{taskId}/result} 请求体) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResultReport { + private String protocol = PROTOCOL; + private Integer attempt; + /** COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报) */ + private String state; + private Boolean localScanComplete; + private Boolean manifestComplete; + private Integer scannedRows; + private Integer pagesProcessed; + private Integer missingRepaired; + private Integer staleRepaired; + private Integer hashConflicts; + private Integer tombstonesRepaired; + private Integer localAhead; + private Integer deferred; + private Integer extraReported; + private Integer extraRetired; + /** 进程重启后从检查点续跑 */ + private Boolean resumed; + private String startedAt; + private List errors = new ArrayList<>(); + } + + /** 一条错误:consumer / task / range / answer 四个坐标必须在场,值文档绝不在场 */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReconcileError { + private String code; + private String taskId; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private String answerUuid; + private Long revision; + private String detail; + } + + /** 结果受理答复:幂等与"旧 attempt 不倒退新 attempt"的结论都在这里回给消费端 */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResultAck { + private String taskId; + private Integer attempt; + private Boolean accepted; + /** 同 taskId+attempt 的重复回报:原结论回给对端,不重复计数 */ + private Boolean duplicate; + /** 比中心已记录的最新 attempt 更旧:留档但不推进状态 */ + private Boolean staleAttempt; + private String taskState; + private String message; + } + + // ==================== 管理面 ==================== + + /** 建任务请求:三种范围(单答卷 / 单表单 / 该消费方全部生效订阅)走同一条入口 */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TaskCreateRequest { + /** ANSWER / FORM / ALL */ + private String scope; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private String answerUuid; + /** 人工触发的幂等键(前端每次点击生成一个;缺键即由服务端补 uuid) */ + private String idempotencyKey; + } + + /** 任务视图(列表与详情共用) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TaskView { + private String taskId; + private String consumerAppKey; + private Long consumerSchoolId; + private String formUuid; + private Integer formVersion; + private String triggerType; + private String operator; + private String state; + private String manifestState; + private Boolean manifestComplete; + private Integer totalRows; + private Integer totalPages; + private Integer materializedRows; + private Integer materializedPages; + private Integer attempt; + private String windowStart; + private String windowEnd; + private String dispatchedAt; + private String finishedAt; + private Boolean cancelRequested; + private String errorCode; + private String errorDetail; + /** 最近一次回报摘要(进度/差异计数) */ + private ResultView latestResult; + } + + /** 结果视图 */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ResultView { + private String taskId; + private Integer attempt; + private String state; + private Boolean localScanComplete; + private Boolean manifestComplete; + private Integer scannedRows; + private Integer pagesProcessed; + private Integer missingRepaired; + private Integer staleRepaired; + private Integer hashConflicts; + private Integer tombstonesRepaired; + private Integer localAhead; + private Integer deferred; + private Integer extraReported; + private Integer extraRetired; + private Integer errorCount; + private List errors = new ArrayList<>(); + private Boolean resumed; + private String reportedAt; + } + + /** 单答卷重放请求(管理面→TASK_2 {@code replayAuthorized}) */ + @Getter + @Setter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ReplayRequest { + private String consumerAppKey; + private Long consumerSchoolId; + private String answerUuid; + private Long expectedRevision; + /** 操作幂等键:同一动作重复点击不刷新退避 */ + private String operationId; + } +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerDeliveryRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerDeliveryRepository.java new file mode 100644 index 0000000..3eac26d --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerDeliveryRepository.java @@ -0,0 +1,48 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.AnswerDelivery; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 投递行仓库(outbox,TASK_2 B.3/B.4)。 + * + *

只提供扫描/定位/分页三类取数,不提供任何删除方法:投递行是审计资产, + * 终态(DEAD/GRANT_REVOKED)留在表里供对账与人工处置,与答卷"无物理删除"同一条纪律 + * ({@code AnswerRepository} 类头)。

+ */ +public interface AnswerDeliveryRepository extends JpaRepository { + + /** 回执端点定位(eventId 全局唯一,另三键做逐字复核) */ + Optional findByEventId(String eventId); + + /** 同 revision 同消费方是否已有行(入队幂等;正常由 uk_delivery_revision 兜底) */ + Optional findByAnswerUuidAndAnswerRevisionAndConsumerAppKeyAndConsumerSchoolId( + String answerUuid, Long answerRevision, String consumerAppKey, Long consumerSchoolId); + + List findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(String answerUuid, String consumerAppKey); + + /** 到点待投(PENDING 首投与 FAILED 退避重试同一条队列) */ + @Query("select d from AnswerDelivery d where d.state in ('PENDING', 'FAILED') " + + "and (d.nextRetryAt is null or d.nextRetryAt <= :now) order by d.id asc") + List findDue(@Param("now") LocalDateTime now, Pageable pageable); + + /** 失败/死信分页({@code failedPage},TASK_3 对账用) */ + @Query("select d from AnswerDelivery d where d.consumerAppKey = :appKey " + + "and d.consumerSchoolId = :schoolId and d.state in ('FAILED', 'DEAD') order by d.id desc") + Page findFailedForConsumer(@Param("appKey") String appKey, + @Param("schoolId") Long schoolId, Pageable pageable); + + /** 该答卷是否还有未落定投递(墓碑/回放前的存在性判定) */ + boolean existsByAnswerUuidAndStateIn(String answerUuid, List states); + + @Query("select count(d) from AnswerDelivery d where d.state = :state") + long countByState(@Param("state") String state); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileManifestRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileManifestRepository.java new file mode 100644 index 0000000..72107b6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileManifestRepository.java @@ -0,0 +1,35 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.AnswerReconcileManifestRow; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * 任务 manifest 读取(TASK_3)。 + * + *

只读接口 + 批量插入:没有 update、没有 delete。清单一旦物化就是这一轮对账的事实源, + * 分页读它、缺席判定也读它;{@code answer} 表此后怎么变都不影响本轮结论(这正是它存在的理由)。

+ */ +public interface AnswerReconcileManifestRepository extends JpaRepository { + + /** 清单分页:游标=上一个 answerUuid(32 位十六进制),不是页码、不是库内 id */ + @Query("select m from AnswerReconcileManifestRow m where m.taskId = :taskId and m.answerUuid > :cursor " + + "order by m.answerUuid asc") + List findPage(@Param("taskId") Long taskId, @Param("cursor") String cursor, + Pageable pageable); + + Optional findByTaskIdAndAnswerUuid(Long taskId, String answerUuid); + + boolean existsByTaskIdAndAnswerUuid(Long taskId, String answerUuid); + + long countByTaskId(Long taskId); + + /** 断点续作物化前的自检:这一轮已经写了多少行(checkpoint 丢失时的兜底口径) */ + @Query("select coalesce(max(m.seqNo), -1) from AnswerReconcileManifestRow m where m.taskId = :taskId") + int maxSeqNo(@Param("taskId") Long taskId); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileReadRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileReadRepository.java new file mode 100644 index 0000000..f3b5d24 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileReadRepository.java @@ -0,0 +1,44 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.FormConsumerGrant; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 对账专用只读仓库(TASK_3 改动点 2/6:"摘要读取用本任务新增只读 Repository/投影")。 + * + *

存在的理由有两层:

+ *
    + *
  • 不给 TASK_2 的文件加快捷查询——{@code AnswerRepository} 是投递与数据面共用的门面, + * 对账要的"按 uuid 升序整表扫"只在物化时用一个瞬间,塞进共用门面就是污染;
  • + *
  • 订阅清单的枚举口径归本任务——TASK_1 的 {@code FormConsumerGrantRepository} 只提供 + * 按表单取订阅(投递路径的形态),调度要的是"全校全表单的生效订阅游标",这是本任务的读取面, + * 条件仍与 TASK_1 逐字一致({@code enabled=1 and subscribeAllowed=1})。
  • + *
+ * + *

返回 {@code Answer} 实体而不是投影:清单摘要里的 hash 必须由 + * {@code AnswerSnapshotService#contentHash} 按 {@code fc.canon.v1} 现算,而它要完整值文档。 + * 取数一律带 keyset 游标与 {@code Pageable} 上限,任何方法都不允许无界拉全表。

+ */ +public interface AnswerReconcileReadRepository extends JpaRepository { + + /** 物化取数:按 (表单, 消费租户域) 的 uuid 升序 keyset,墓碑一并出(清单含缺席事实) */ + @Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schoolId " + + "and a.uuid > :cursor order by a.uuid asc") + List findScopeAfterUuid(@Param("formUuid") String formUuid, @Param("schoolId") Long schoolId, + @Param("cursor") String cursor, Pageable pageable); + + /** 建任务前的资源预算:范围内答卷总数(含墓碑),超限即拒绝建任务而不是当场载表 */ + @Query("select count(a.id) from Answer a where a.formUuid = :formUuid and a.schId = :schoolId") + long countScope(@Param("formUuid") String formUuid, @Param("schoolId") Long schoolId); + + /** 调度枚举生效订阅(游标=id,升序):判据与 TASK_1 的 findEnabledSubscriptions 同一条 */ + @Query("select g from FormConsumerGrant g where g.enabled = 1 and g.subscribeAllowed = 1 " + + "and g.readAllowed = 1 and g.id > :cursor order by g.id asc") + List findLiveSubscriptionsAfterId(@Param("cursor") Long cursor, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileResultRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileResultRepository.java new file mode 100644 index 0000000..9e58091 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileResultRepository.java @@ -0,0 +1,36 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.AnswerReconcileResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * 对账结果仓库(TASK_3)。 + * + *

一 attempt 一行、{@code (task_id, attempt)} 唯一:重复回报由服务层读回原行按幂等吸收, + * 不做覆盖更新;比最新 attempt 更旧的回报由 {@link #findLatest} 的返回值判方向后拒绝推进。

+ */ +public interface AnswerReconcileResultRepository extends JpaRepository { + + Optional findByTaskIdAndAttempt(Long taskId, Integer attempt); + + Optional findFirstByTaskIdOrderByIdDesc(Long taskId); + + @Query("select max(r.attempt) from AnswerReconcileResult r where r.taskId = :taskId") + Integer maxAttempt(@Param("taskId") Long taskId); + + @Query("select r from AnswerReconcileResult r where r.taskId = :taskId order by r.attempt asc") + List findByTask(@Param("taskId") Long taskId); + + /** 失败明细分页(管理面查看与重放入口的取数) */ + @Query("select r from AnswerReconcileResult r where r.consumerAppKey = :appKey " + + "and r.consumerSchoolId = :schoolId and r.state in ('PARTIAL', 'FAILED') order by r.id desc") + Page findUnresolved(@Param("appKey") String appKey, + @Param("schoolId") Long schoolId, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileTaskRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileTaskRepository.java new file mode 100644 index 0000000..bd34bfc --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerReconcileTaskRepository.java @@ -0,0 +1,162 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.AnswerReconcileTask; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import javax.persistence.LockModeType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 对账任务仓库(TASK_3)。 + * + *

三件事在这里定型:①领取用条件更新而不是"读—改—写"(两个扫描线程同时看到同一条 + * QUEUED 时,只有一个能刷新租约,另一个更新 0 行即退出——这就是任务书要求的单并发); + * ②同一坐标只允许一个活跃任务({@code countActiveAt} 是它的判据); + * ③没有任何删除方法:任务、清单、结果都是审计资产,终态留表供复盘。

+ */ +public interface AnswerReconcileTaskRepository extends JpaRepository { + + Optional findByTaskUuid(String taskUuid); + + Optional findByIdempotencyKey(String idempotencyKey); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from AnswerReconcileTask t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + /** 待物化(建任务后由扫描异步物化,全量人工任务因此不会在请求线程里载表) */ + @Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' " + + "and t.manifestState = 'PENDING' and t.cancelRequested = 0 order by t.id asc") + List findPendingMaterialization(Pageable pageable); + + /** 物化中断后需续跑的半成品(checkpoint 非空,仍在跑的那条租约未到期不会被领走) */ + @Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' " + + "and t.manifestState = 'MATERIALIZING' and (t.leaseUntil is null or t.leaseUntil <= :now) " + + "and t.cancelRequested = 0 order by t.id asc") + List findStaleMaterialization(@Param("now") LocalDateTime now, Pageable pageable); + + /** 清单已完整、到点可派发(首派与退避重派同一条队列) */ + @Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' and t.manifestComplete = 1 " + + "and t.cancelRequested = 0 and (t.nextDispatchAt is null or t.nextDispatchAt <= :now) " + + "order by t.id asc") + List findDispatchable(@Param("now") LocalDateTime now, Pageable pageable); + + /** 租约超期:按 attempt+1 有限退避重派(业务结果仍按 taskId/attempt 幂等) */ + @Query("select t from AnswerReconcileTask t where t.state in ('DISPATCHED', 'RUNNING') " + + "and t.leaseUntil is not null and t.leaseUntil <= :now and t.cancelRequested = 0 " + + "order by t.id asc") + List findExpiredLease(@Param("now") LocalDateTime now, Pageable pageable); + + /** 同坐标活跃任务数(单并发建任务判据) */ + @Query("select count(t) from AnswerReconcileTask t where t.consumerAppKey = :appKey " + + "and t.consumerSchoolId = :schoolId and t.formUuid = :formUuid " + + "and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')") + long countActiveAt(@Param("appKey") String appKey, @Param("schoolId") Long schoolId, + @Param("formUuid") String formUuid); + + /** + * 同一消费方当前在途(已派发或执行中)的任务数——全量人工任务"单并发"的判据: + * 一个消费者一次只跑一轮对账,避免两个 Worker 同时扫同一份本地集合互相踩检查点。 + */ + @Query("select count(t) from AnswerReconcileTask t where t.consumerAppKey = :appKey " + + "and t.consumerSchoolId = :schoolId and t.state in ('DISPATCHED', 'RUNNING') " + + "and t.id <> :exceptId") + long countInFlightForConsumer(@Param("appKey") String appKey, @Param("schoolId") Long schoolId, + @Param("exceptId") Long exceptId); + + /** 管理面列表:按消费域(超管可看全部,校级只看本域) */ + @Query("select t from AnswerReconcileTask t where (:appKey is null or t.consumerAppKey = :appKey) " + + "and (:schoolId is null or t.consumerSchoolId = :schoolId) " + + "and (:state is null or t.state = :state) order by t.id desc") + Page findForAdmin(@Param("appKey") String appKey, @Param("schoolId") Long schoolId, + @Param("state") String state, Pageable pageable); + + // ===== 条件更新(并发只落一次)===== + + /** 领取物化权:PENDING 或超期的 MATERIALIZING 才能被领走 */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.manifestState = 'MATERIALIZING', t.leaseUntil = :leaseUntil " + + "where t.id = :id and t.state = 'QUEUED' and t.cancelRequested = 0 " + + "and (t.manifestState = 'PENDING' or ((t.manifestState = 'MATERIALIZING') " + + "and (t.leaseUntil is null or t.leaseUntil <= :now)))") + int claimMaterialization(@Param("id") Long id, @Param("now") LocalDateTime now, + @Param("leaseUntil") LocalDateTime leaseUntil); + + /** 领取派发权:只有租约到期或还没派过的行会被领走 */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.state = 'DISPATCHED', t.attempt = t.attempt + 1, " + + "t.leaseUntil = :leaseUntil, t.dispatchedAt = :now, t.nextDispatchAt = null " + + "where t.id = :id and t.state = 'QUEUED' and t.manifestComplete = 1 and t.cancelRequested = 0 " + + "and (t.nextDispatchAt is null or t.nextDispatchAt <= :now)") + int claimForDispatch(@Param("id") Long id, @Param("now") LocalDateTime now, + @Param("leaseUntil") LocalDateTime leaseUntil); + + /** 派发失败后退避重排(不占用 attempt 之外的次数预算) */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.state = 'QUEUED', t.nextDispatchAt = :nextAt, " + + "t.leaseUntil = null, t.errorCode = :errorCode, t.errorDetail = :detail " + + "where t.id = :id and t.state = 'DISPATCHED'") + int rescheduleDispatch(@Param("id") Long id, @Param("nextAt") LocalDateTime nextAt, + @Param("errorCode") String errorCode, @Param("detail") String detail); + + /** 租约定期续租(消费端进度回报会带上 taskId/attempt,中心据此刷新) */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.leaseUntil = :leaseUntil, t.state = 'RUNNING' " + + "where t.id = :id and t.state in ('DISPATCHED', 'RUNNING')") + int markRunning(@Param("id") Long id, @Param("leaseUntil") LocalDateTime leaseUntil); + + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.leaseUntil = :leaseUntil, t.state = 'RUNNING' " + + "where t.id = :id and t.attempt = :attempt and t.cancelRequested = 0 " + + "and t.state in ('DISPATCHED', 'RUNNING')") + int renewRunning(@Param("id") Long id, @Param("attempt") Integer attempt, + @Param("leaseUntil") LocalDateTime leaseUntil); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from AnswerReconcileTask t where t.taskUuid = :uuid") + Optional lockByTaskUuid(@Param("uuid") String uuid); + + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.state = :newState, t.finishedAt = :now, " + + "t.leaseUntil = null, t.nextDispatchAt = null, t.errorCode = :errorCode, t.errorDetail = :detail " + + "where t.id = :id and t.attempt = :attempt and t.cancelRequested = 0 " + + "and t.state in ('DISPATCHED', 'RUNNING')") + int finishAttempt(@Param("id") Long id, @Param("attempt") Integer attempt, + @Param("newState") String newState, @Param("now") LocalDateTime now, + @Param("errorCode") String errorCode, @Param("detail") String detail); + + /** 终态推进:只有非终态行会被改写(重复回报幂等,不由这里保证——这里只防倒退覆盖) */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.state = :newState, t.finishedAt = :now, " + + "t.leaseUntil = null, t.nextDispatchAt = null, t.errorCode = :errorCode, t.errorDetail = :detail " + + "where t.id = :id and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')") + int advanceTerminal(@Param("id") Long id, @Param("newState") String newState, + @Param("now") LocalDateTime now, @Param("errorCode") String errorCode, + @Param("detail") String detail); + + /** 租约超期重排:DISPATCHED/RUNNING 回到 QUEUED 等下一次退避派发 */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.state = 'QUEUED', t.nextDispatchAt = :nextAt, " + + "t.leaseUntil = null, t.errorCode = :errorCode, t.errorDetail = :detail " + + "where t.id = :id and t.state in ('DISPATCHED', 'RUNNING') and (t.leaseUntil is null " + + "or t.leaseUntil <= :now)") + int requeueExpiredLease(@Param("id") Long id, @Param("now") LocalDateTime now, + @Param("nextAt") LocalDateTime nextAt, @Param("errorCode") String errorCode, + @Param("detail") String detail); + + /** 取消收敛:非终态一律落 CANCELLED(终态行更新 0 行,取消动作因此是幂等的) */ + @Modifying(clearAutomatically = true) + @Query("update AnswerReconcileTask t set t.state = 'CANCELLED', t.cancelRequested = 1, " + + "t.finishedAt = :now, t.leaseUntil = null, t.nextDispatchAt = null, " + + "t.errorCode = 'CANCELLED', t.errorDetail = :detail " + + "where t.id = :id and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')") + int markCancelled(@Param("id") Long id, @Param("now") LocalDateTime now, @Param("detail") String detail); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java index f516b24..bc2178f 100644 --- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java @@ -3,6 +3,7 @@ package com.yunhai.formcenter.repository; import com.yunhai.formcenter.entity.Answer; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -25,6 +26,21 @@ public interface AnswerRepository extends JpaRepository { Optional findByUuidAndSchId(String uuid, Long schId); + /** + * 快照修订号行级 CAS(v2 投递契约 §2,TASK_2 B.1 的并发控制落点)。 + * + *

{@code UPDATE} 自身拿行锁:并发改同一条答卷时,后到的事务在这里被挡到前一个提交, + * 回来后 {@code WHERE answer_revision = :expected} 不再匹配 → 返回 0 行 → 调用方抛异常整事务回滚。 + * 与 outbox 的 {@code uk_delivery_revision} 是同一条不变式的两道闸:CAS 挡"取到同一个号", + * 唯一键挡"已经写出去的两行同版不同内容"。

+ * + *

必须在改动实体的 {@code answerRevision} 字段之前调用:JPQL 批量更新绕过脏检查, + * 先改字段会让自动 flush 把新号写进去、CAS 再也匹配不上旧号(自我拒绝)。

+ */ + @Modifying + @Query("update Answer a set a.answerRevision = :next where a.id = :id and a.answerRevision = :expected") + int compareAndSetRevision(@Param("id") Long id, @Param("expected") Long expected, @Param("next") Long next); + /** * 按 (域, uuid 集合) 批量定位(T14 守卫:数据面入口要按答卷反查它所属的表单才能判 ALL 档)。 * 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。 diff --git a/src/main/java/com/yunhai/formcenter/repository/FormConsumerGrantRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormConsumerGrantRepository.java new file mode 100644 index 0000000..36f86ca --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/FormConsumerGrantRepository.java @@ -0,0 +1,40 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.FormConsumerGrant; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 消费授权仓库(v3 §2)。三元唯一键就是单点定位入口;批量查询服务目录端点, + * 免得逐张表回库(一校已发布表单量级个位数到几百,同 {@link FormRepository#findLiveBySch} 的量级论证)。 + * + *

撤销用 {@code enabled=0} 表达,所以判授权的查询必须带 enabled 条件—— + * 漏掉它就会让已撤销的授权继续生效(fail-open),这条是本仓库唯一不能写错的地方。

+ */ +public interface FormConsumerGrantRepository extends JpaRepository { + + Optional findByFormUuidAndConsumerSchoolIdAndConsumerAppKey( + String formUuid, Long consumerSchoolId, String consumerAppKey); + + /** 管理端 grant 清单(含已撤销行:撤销后仍要看得见、可重申) */ + @Query("select g from FormConsumerGrant g where g.formUuid = :formUuid order by g.id asc") + List findLiveByForm(@Param("formUuid") String formUuid); + + /** 订阅面取授权:只认生效且开了订阅的行(v3 §7,TASK_3 投递判据同源) */ + @Query("select g from FormConsumerGrant g where g.formUuid = :formUuid " + + "and g.consumerSchoolId = :schoolId and g.enabled = 1 and g.subscribeAllowed = 1") + List findEnabledSubscriptions(@Param("formUuid") String formUuid, + @Param("schoolId") Long schoolId); + + /** 目录端点批量取某消费方在某校的生效授权(读判定用) */ + @Query("select g from FormConsumerGrant g where g.formUuid in (:uuids) " + + "and g.consumerSchoolId = :schoolId and g.consumerAppKey = :appKey and g.enabled = 1") + List findForConsumer(@Param("uuids") Collection uuids, + @Param("schoolId") Long schoolId, + @Param("appKey") String appKey); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormOwnershipRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormOwnershipRepository.java new file mode 100644 index 0000000..2cb79c2 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/FormOwnershipRepository.java @@ -0,0 +1,24 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.FormOwnership; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * owner 绑定仓库(v3 §2)。判定一律按 uuid 取,不带租户条件——租户域复核在 + * {@code FormConsumerAccessService} 里做(同 {@link FormRepository#findByUuid} 的分工: + * uuid 是全局坐标,"这条绑定属不属于当前域"是应用层规则)。 + */ +public interface FormOwnershipRepository extends JpaRepository { + + Optional findByFormUuid(String formUuid); + + /** 目录端点的批量取绑定(避免逐张表 N+1 查询) */ + @Query("select o from FormOwnership o where o.formUuid in (:uuids)") + List findByFormUuidIn(@Param("uuids") Collection uuids); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/SubmissionContextRepository.java b/src/main/java/com/yunhai/formcenter/repository/SubmissionContextRepository.java new file mode 100644 index 0000000..19c3219 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/SubmissionContextRepository.java @@ -0,0 +1,25 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.SubmissionContext; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * 可信提交上下文仓库(TASK_2 A.2/A.3)。 + * + *

定位方式只有三种,各自对应契约里的一条不变式:

+ *
    + *
  • {@code (consumerAppKey, intentId)}——同意图重试必须回到同一上下文(uk_ctx_intent);
  • + *
  • {@code tokenDigest}——填报页 redeem,只按摘要查,明文不入库(uk_ctx_token_digest);
  • + *
  • {@code answerUuid}——收据端点按答卷反查意图(uk_ctx_answer 保证一对一)。
  • + *
+ */ +public interface SubmissionContextRepository extends JpaRepository { + + Optional findByConsumerAppKeyAndIntentId(String consumerAppKey, String intentId); + + Optional findByTokenDigest(String tokenDigest); + + Optional findByAnswerUuid(String answerUuid); +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java index d93bf25..a708143 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java @@ -9,6 +9,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog; import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.StatusLog; +import com.yunhai.formcenter.model.AnswerDeliveryModels; import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.repository.AnswerOpLogRepository; @@ -72,6 +73,8 @@ public class AnswerAdminService { private final AnswerScopeQueryBuilder queryBuilder; private final TransactionTemplate transactionTemplate; private final FcEventPublisher events; + /** v2 快照投递:取号与 outbox 入队都在每条独立事务内(TASK_2 B.1,含改状态与批量出口) */ + private final AnswerDeliveryService deliveries; // ==================== 单条 ==================== @@ -143,11 +146,13 @@ public class AnswerAdminService { assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson); answer.setValuesJson(valuesJson); + deliveries.nextRevision(answer); answerRepository.save(answer); // 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变 indexService.rebuild(answer, schemaJson); // 事件:非冻结编辑(冻结在上面就拒了,走不到这里)= updated(契约 v2 §6.2 发布点之一) events.answerUpdated(answer); + deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED); opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(), req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")" : "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary()); @@ -157,6 +162,7 @@ public class AnswerAdminService { private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) { Answer answer = requireManageableAnswer(operator, schId, answerUuid); answer.setIsDeleted(1); + deliveries.nextRevision(answer); answerRepository.save(answer); indexService.clear(answer.getId()); // 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。 @@ -168,6 +174,8 @@ public class AnswerAdminService { operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)"); // 事件:tombstoned(管理面单条与批量共用本方法,所以两个入口一处发布,§6.2) events.answerTombstoned(answer); + // 批量公共出口同样在每条独立事务里取号+入队:不存在"批量只改行不投快照"的旁路 + deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_TOMBSTONED); } private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req, @@ -188,6 +196,7 @@ public class AnswerAdminService { } String oldStatus = answer.getStatus(); answer.setStatus(status); + deliveries.nextRevision(answer); answerRepository.save(answer); StatusLog entry = new StatusLog(); entry.setAnswerId(answer.getId()); @@ -198,6 +207,9 @@ public class AnswerAdminService { opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS, operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status + "(无转移规则校验)"); + // 任务书点名的漏项防线:status 是对外快照成员,改状态必须同样取号并入队(updated), + // 否则"审核已通过"这类事实对消费方永远不可见,而 outbox 看上去还是完整的 + deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED); } // ==================== 辅助 ==================== diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerCanonicalizer.java b/src/main/java/com/yunhai/formcenter/service/AnswerCanonicalizer.java new file mode 100644 index 0000000..e4aced7 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerCanonicalizer.java @@ -0,0 +1,188 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * 答卷快照的规范化与哈希(answer-delivery-v2 §4,TASK_2 B.1)。 + * + *

为什么要有版本化规范化,而不是直接对 {@code valuesJson} 原文求哈希:投递侧要在 + * "同一 revision 只能对应一份 hash"这条不变式上落库唯一键,消费侧要用同一算法复算来判 + * "同版异 hash=冲突待核验"。成员顺序取决于来源(值文档是客户端 JSON 原文、身份列是服务端拼装), + * 不规范化就会让同一份数据产出两种字节串——那等于把幂等判据建在随机数上。

+ * + *

{@code fc.canon.v1} 规则闭集(要改语义就升算法号,不许原地改):

+ *
    + *
  1. 对象:键按 UTF-16 代码元字典序({@link String#compareTo})升序递归输出—— + * 刻意钉比较器而不是"码点序":两端都是 JVM,同一个比较器才逐字一致;
  2. + *
  3. 数组:顺序保留(多选/排序是业务语义),逐元素递归;
  4. + *
  5. 数字:{@code decimalValue().stripTrailingZeros().toPlainString()}, + * 即 {@code 100.0→100}、{@code 1E+2→100},无指数、无多余前导零;
  6. + *
  7. 字符串:仅转义 {@code " \ 与控制字符}({@code \b\f\n\r\t} 用短转义, + * 其余 C0 用"反斜杠 + u + 00 + 两位大写十六进制",非 ASCII(含中文)原样输出, + * 哈希按 UTF-8 取字节;
  8. + *
  9. 空值策略:显式 null 成员保留(缺失键与 {@code "k":null} 是两种事实, + * 合并会丢"字段被清空"这次变化);{@code missing}/{@code null} 节点视为不存在的成员;
  10. + *
  11. 排除项:快照顶层的 {@code intent} 成员不参与哈希——它是"仅对该 consumer 可见"的 + * 关联块,同 revision 会因为消费方不同而不同,留着就与不变式①直接矛盾; + * 投递时间/重试计数/消费端扩展列都是信封或本地列,压根不进快照。
  12. + *
+ */ +public final class AnswerCanonicalizer { + + /** 规范化算法号(信封里不回带,由 protocolVersion=fc.answer.snapshot.v2 隐含锁定) */ + public static final String CANONICALIZATION_VERSION = "fc.canon.v1"; + + public static final String HASH_ALGORITHM = "SHA-256"; + + /** 规则⑥:顶层排除成员 */ + private static final String EXCLUDED_TOP_LEVEL_MEMBER = "intent"; + + private AnswerCanonicalizer() { + } + + /** 全量规范化(含 intent 块本身不参与,见规则⑥——两入口同一条剔除口径) */ + public static String canonical(JsonNode node) { + StringBuilder sb = new StringBuilder(256); + write(node == null ? null : stripExcluded(node), sb); + return sb.toString(); + } + + /** + * 快照哈希:64 位小写十六进制 SHA-256(UTF-8 字节)。 + * 消费端复算出的值与本值不等而 revision 相等 ⇒ 冲突待核验,不覆盖。 + */ + public static String hashSnapshot(JsonNode snapshot) { + return sha256Hex(canonical(snapshot)); + } + + private static JsonNode stripExcluded(JsonNode node) { + if (node == null || !node.isObject()) { + return node; + } + ObjectNode copy = ((ObjectNode) node).deepCopy(); + copy.remove(EXCLUDED_TOP_LEVEL_MEMBER); + return copy; + } + + private static void write(JsonNode node, StringBuilder sb) { + if (node == null || node.isNull() || node.isMissingNode()) { + sb.append("null"); + return; + } + if (node.isObject()) { + List names = new ArrayList<>(); + Iterator> fields = node.fields(); + while (fields.hasNext()) { + names.add(fields.next().getKey()); + } + names.sort(String::compareTo); + sb.append('{'); + boolean first = true; + for (String name : names) { + if (!first) { + sb.append(','); + } + first = false; + writeString(name, sb); + sb.append(':'); + write(node.get(name), sb); + } + sb.append('}'); + return; + } + if (node.isArray()) { + sb.append('['); + for (int i = 0; i < node.size(); i++) { + if (i > 0) { + sb.append(','); + } + write(node.get(i), sb); + } + sb.append(']'); + return; + } + if (node.isNumber()) { + sb.append(canonicalNumber(node.decimalValue())); + return; + } + if (node.isBoolean()) { + sb.append(node.booleanValue() ? "true" : "false"); + return; + } + writeString(node.asText(), sb); + } + + /** 规则③:整值与小数同一条十进制通路 */ + private static String canonicalNumber(BigDecimal raw) { + BigDecimal stripped = raw.stripTrailingZeros(); + // scale<0 时 toPlainString 仍会给 "1E+2" 形态的补零,这里手动摊平 + if (stripped.scale() < 0) { + stripped = stripped.setScale(0); + } + return stripped.toPlainString(); + } + + private static void writeString(String value, StringBuilder sb) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append("\\u00").append(String.format("%02X", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static String sha256Hex(String input) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance(HASH_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + // JDK 保证有 SHA-256;真缺了就是环境坏了,不能降级成"没有哈希"继续投出去 + throw new IllegalStateException("SHA-256 不可用(JVM 环境异常)", e); + } + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerDeliveryService.java b/src/main/java/com/yunhai/formcenter/service/AnswerDeliveryService.java new file mode 100644 index 0000000..063574f --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerDeliveryService.java @@ -0,0 +1,801 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerDelivery; +import com.yunhai.formcenter.model.AnswerDeliveryModels; +import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReport; +import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReportView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage; +import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantSnapshotView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.Snapshot; +import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotEnvelope; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant; +import com.yunhai.formcenter.repository.AnswerDeliveryRepository; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageDeliveryMode; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.connection.CorrelationData; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 快照投递服务(answer-delivery-v2 §3/§5,TASK_2 B.1/B.3/B.4/B.6 + TASK_3 扩展点)。 + * + *

四件事按顺序各归各位,混一条就不可靠:

+ *
    + *
  1. 入队=答卷事务内的一次写({@link #enqueueInTransaction}):outbox 行与答卷行同生同死, + * 进程在提交后、发送前被杀也只是"晚投",不是"丢投"——这是本类与旧 + * {@code FcEventPublisher}(afterCommit 才落"发"这个动作、丢了由拉取兜底)的根本分工差别, + * 任务书明令不许照搬旧形态;
  2. + *
  3. afterCommit 只唤醒扫描({@link #wakeScan}):扫的是库里的行,不是内存里的闭包;
  4. + *
  5. 发送前再核授权({@link #publish}):行的目标列只证明"入队那刻"合法, + * 撤权后不重放旧 grant(v2 §5),但已交出去的明文副本不承诺远程收回;
  6. + *
  7. broker 收下≠消费方应用:confirm 只写 {@code broker_accepted_at}, + * {@code consumer_applied_at} 只能由消费方签名回执写({@link #recordApplied})。
  8. + *
+ * + *

没配 broker 时本类不 no-op 丢事件:行留在 {@code PENDING},启动与每次扫描各记一条日志, + * 状态可用 {@code failedPage} / {@code countByState} 观察(B.4"保留待投递并可观察")。

+ */ +@Slf4j +@Service +public class AnswerDeliveryService { + + private static final DateTimeFormatter OCCURRED_AT = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final DateTimeFormatter TS = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + private static final String HEADER_DELIVERY_ID = "fc-delivery-id"; + /** 退避序列:base×2^n,封顶 cap(broker 故障时不许热循环) */ + private static final List SCANNABLE_STATES = + Arrays.asList(AnswerDelivery.STATE_PENDING, AnswerDelivery.STATE_FAILED); + + private final AnswerDeliveryRepository deliveryRepository; + private final AnswerRepository answerRepository; + private final AnswerSnapshotService snapshots; + private final FormConsumerAccessService accessService; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + private final ObjectProvider templates; + + /** 发布方身份(服务端配置;不接受消费方申报,也不从资源 appCode 推) */ + @Value("${fc.answer.delivery.source-app-key:${spring.application.name:form-center}}") + private String sourceAppKey; + + @Value("${spring.rabbitmq.host:}") + private String host; + + /** broker 配了但没开 correlated confirm:拿不到"总线收下"的证据,扫描必须拒绝发送(B.4) */ + @Value("${spring.rabbitmq.publisher-confirm-type:none}") + private String confirmType; + + @Value("${fc.answer.delivery.batch-size:50}") + private int batchSize; + + @Value("${fc.answer.delivery.max-retries:8}") + private int maxRetries; + + @Value("${fc.answer.delivery.backoff-base-seconds:30}") + private long backoffBaseSeconds; + + @Value("${fc.answer.delivery.backoff-cap-seconds:3600}") + private long backoffCapSeconds; + + private final AtomicBoolean exchangeDeclared = new AtomicBoolean(false); + private final AtomicBoolean missingMqLogged = new AtomicBoolean(false); + + /** 扫描专用单线程:串行发送,避免同一行被两个线程各发一次 */ + private static final ExecutorService SCAN_EXECUTOR = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "fc-answer-delivery-scan"); + t.setDaemon(true); + return t; + }); + + @Autowired + public AnswerDeliveryService(AnswerDeliveryRepository deliveryRepository, + AnswerRepository answerRepository, + AnswerSnapshotService snapshots, + FormConsumerAccessService accessService, + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate, + ObjectProvider templates) { + this.deliveryRepository = deliveryRepository; + this.answerRepository = answerRepository; + this.snapshots = snapshots; + this.accessService = accessService; + this.objectMapper = objectMapper; + this.transactionTemplate = transactionTemplate; + this.templates = templates; + } + + @PostConstruct + public void logState() { + if (!hostConfigured()) { + log.info("[v2投递] 未配置 RABBITMQ_HOST:outbox 仍照常落库并留在 PENDING(不丢事件,B.4)"); + return; + } + if (!"correlated".equalsIgnoreCase(confirmType)) { + log.error("[v2投递] 已配置 broker 但 spring.rabbitmq.publisher-confirm-type={} —— " + + "拿不到逐条 confirm 就不发送(宁可积压也不按未确认投递),请置 correlated", confirmType); + return; + } + registerCallbacks(); + log.info("[v2投递] 启用:exchange={},sourceAppKey={},批量={},最大重试={}", + AnswerDeliveryModels.EXCHANGE, sourceAppKey, batchSize, maxRetries); + } + + public boolean hostConfigured() { + return host != null && !host.trim().isEmpty(); + } + + /** 投递通道是否可用(broker + confirm 双条件;不满足=只入队不发送) */ + public boolean sendEnabled() { + return hostConfigured() && "correlated".equalsIgnoreCase(confirmType) + && templates.getIfAvailable() != null; + } + + // ==================== 取号(答卷事务内) ==================== + + /** + * 取下一个快照修订号(行级 CAS,B.1 的并发控制)。 + * + *

调用时机是业务字段改完之后、{@code save} 之前:JPQL 批量更新前 Hibernate 会先 + * 自动 flush 已改字段(此时行上仍是旧号,CAS 匹配得上),随后这条 UPDATE 把号推到 next + * 并拿到行锁;并发对手在同一行上被挡到本事务提交,回来时 {@code WHERE} 不再匹配 → 0 行 → 拒。 + * 顺序反过来(先改实体字段再 CAS)会造成自我拒绝。

+ */ + public long nextRevision(Answer answer) { + if (answer == null || answer.getId() == null) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "取快照修订号需要已落库的答卷行"); + } + long current = answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision(); + long next = current + 1; + int updated = answerRepository.compareAndSetRevision(answer.getId(), current, next); + if (updated != 1) { + throw new BizException(ErrorCode.CUSTOM, + "答卷并发修改冲突(快照修订号 CAS 未命中),本次操作回滚,请重试:" + answer.getUuid()); + } + answer.setAnswerRevision(next); + return next; + } + + // ==================== 入队(答卷事务内) ==================== + + /** + * 在调用方事务内为该答卷的当前 revision 建逐订阅投递行。 + * + *

无订阅的简单表单返回空列表——答卷照常提交,不创建任何消费投递记录(B.2 末句)。 + * 撞 {@code uk_delivery_revision} 说明另一个事务已把同一 revision 投出去了(CAS 没挡住的极端形态), + * 整事务回滚是唯一安全处置,不做"改号重试"。

+ */ + public int enqueueInTransaction(Answer answer, String eventType) { + if (answer == null || answer.getUuid() == null || answer.getAnswerRevision() == null) { + return 0; + } + List grants = + accessService.listEnabledSubscriptions(answer.getFormUuid(), answer.getSchId()); + if (grants.isEmpty()) { + return 0; + } + String contentHash = snapshots.contentHash(answer); + LocalDateTime occurredAt = answer.getUpdateTime() == null ? LocalDateTime.now() : answer.getUpdateTime(); + for (SubscriptionGrant grant : grants) { + AnswerDelivery row = new AnswerDelivery(); + row.setEventId(UUID.randomUUID().toString()); + row.setProtocolVersion(AnswerDeliveryModels.PROTOCOL_VERSION); + row.setEventType(eventType); + row.setSourceAppKey(sourceAppKey); + row.setConsumerAppKey(grant.getConsumerAppKey()); + row.setConsumerSchoolId(grant.getConsumerSchoolId()); + row.setGrantVersion(grant.getGrantVersion()); + row.setFormUuid(answer.getFormUuid()); + row.setFormVersion(answer.getVersion()); + row.setAnswerUuid(answer.getUuid()); + row.setAnswerRevision(answer.getAnswerRevision()); + row.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()) ? 1 : 0); + row.setContentHash(contentHash); + row.setOccurredAt(occurredAt); + row.setSnapshotJson(writeSnapshot(answer, grant)); + row.setState(AnswerDelivery.STATE_PENDING); + row.setNextRetryAt(occurredAt); + try { + deliveryRepository.saveAndFlush(row); + } catch (DataIntegrityViolationException e) { + // CAS 之后仍有同 revision 投递行=另一事务已抢先落定同一份快照。 + // 抛业务异常而不是继续透传 DIVE:提交链路外层的 DIVE 处理会把"撞幂等键"翻译成 + // "返回原答卷",那正是这里最不该出现的语义(并发丢更新会被伪装成重放成功)。 + throw new BizException(ErrorCode.CUSTOM, "并发修改冲突(同一快照修订号已有投递行),本次提交回滚,请重试"); + } + } + log.info("[v2投递] 入队 answer={} rev={} event={} 目标={}", answer.getUuid(), + answer.getAnswerRevision(), eventType, grants.size()); + return grants.size(); + } + + private String writeSnapshot(Answer answer, SubscriptionGrant grant) { + Snapshot snapshot = snapshots.forConsumer(answer, grant.getConsumerAppKey(), grant.getConsumerSchoolId()); + try { + return objectMapper.writeValueAsString(snapshot); + } catch (Exception e) { + throw new IllegalStateException("快照序列化失败(答卷提交必须一起失败,不能留无载荷投递行):" + + answer.getUuid(), e); + } + } + + /** + * 入队 + 唤醒(写侧唯一该调的口):{@link #enqueueInTransaction} 必须在调用方事务内, + * {@link #wakeScan} 自己识别 afterCommit,调用点不需要知道事务边界在哪。 + */ + public void enqueueAndWake(Answer answer, String eventType) { + if (enqueueInTransaction(answer, eventType) > 0) { + wakeScan(); + } + } + + // ==================== 唤醒扫描(afterCommit 的唯一职责) ==================== + + /** 事务提交后唤醒一次扫描;无活动事务(单测/非事务调用)就地异步跑 */ + public void wakeScan() { + if (!sendEnabled()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + SCAN_EXECUTOR.submit(AnswerDeliveryService.this::sendDueBatchQuietly); + } + }); + return; + } + SCAN_EXECUTOR.submit(AnswerDeliveryService.this::sendDueBatchQuietly); + } + + /** 定时/唤醒共用:一次批量,异常只记日志(扫描器不许把业务线程带崩) */ + public int sendDueBatchQuietly() { + if (!sendEnabled()) { + if (hostConfigured() || !missingMqLogged.getAndSet(true)) { + missingMqLogged.set(true); + if (!hostConfigured()) { + log.info("[v2投递] 无 broker:{} 条 PENDING 保留待发(countByState 可观察)", + deliveryRepository.countByState(AnswerDelivery.STATE_PENDING)); + } + } + return 0; + } + try { + return sendDueBatch(); + } catch (Exception e) { + log.warn("[v2投递] 扫描异常(不重试到业务线程):{}", e.toString()); + return 0; + } + } + + /** 到点批量发送(confirm 异步回写状态,本方法只负责"发出去"与"发不出去就退避") */ + public int sendDueBatch() { + List due = deliveryRepository.findDue(LocalDateTime.now(), + PageRequest.of(0, Math.max(1, batchSize))); + int sent = 0; + for (AnswerDelivery row : due) { + if (publish(row)) { + sent++; + } + } + return sent; + } + + private boolean publish(AnswerDelivery row) { + SubscriptionGrant current = findLiveGrant(row); + if (current == null || row.getGrantVersion() == null + || current.getGrantVersion() != row.getGrantVersion()) { + markTerminal(row.getId(), AnswerDelivery.STATE_GRANT_REVOKED, + current == null ? "订阅授权已失效(发布前复核)" + : "授权纪元已进位(入队 " + row.getGrantVersion() + " → 现 " + + current.getGrantVersion() + "),不重放旧 grant"); + log.warn("[v2投递] 发布前 grant 复核未过:eventId={} consumer={}@{} form={} state={}", + row.getEventId(), row.getConsumerAppKey(), row.getConsumerSchoolId(), + row.getFormUuid(), AnswerDelivery.STATE_GRANT_REVOKED); + return false; + } + RabbitTemplate template = templates.getIfAvailable(); + if (template == null) { + markFailed(row, "容器内无 RabbitTemplate"); + return false; + } + try { + declareExchangeOnce(template); + byte[] body = objectMapper.writeValueAsString(envelopeOf(row)).getBytes(StandardCharsets.UTF_8); + MessageProperties props = new MessageProperties(); + props.setMessageId(row.getEventId()); + props.setCorrelationId(String.valueOf(row.getId())); + props.setHeader(HEADER_DELIVERY_ID, String.valueOf(row.getId())); + props.setDeliveryMode(MessageDeliveryMode.PERSISTENT); + props.setContentType(MessageProperties.CONTENT_TYPE_JSON); + props.setContentEncoding(StandardCharsets.UTF_8.name()); + template.send(AnswerDeliveryModels.EXCHANGE, routingKey(row), new Message(body, props), + new CorrelationData(String.valueOf(row.getId()))); + return true; + } catch (Exception e) { + markFailed(row, e.toString()); + log.warn("[v2投递] 发送异常,退避重试:eventId={} cause={}", row.getEventId(), e.toString()); + return false; + } + } + + /** routing key:显式消费应用 + 租户域(不是资源 appCode,B.3) */ + public static String routingKey(AnswerDelivery row) { + return routingKey(row.getConsumerAppKey(), row.getConsumerSchoolId()); + } + + public static String routingKey(String consumerAppKey, Long consumerSchoolId) { + return "fc.snapshot.v2." + consumerAppKey + "." + consumerSchoolId; + } + + private SnapshotEnvelope envelopeOf(AnswerDelivery row) { + SnapshotEnvelope envelope = new SnapshotEnvelope(); + envelope.setProtocolVersion(row.getProtocolVersion()); + envelope.setEventId(row.getEventId()); + envelope.setEventType(row.getEventType()); + envelope.setSourceAppKey(row.getSourceAppKey()); + envelope.setConsumerAppKey(row.getConsumerAppKey()); + envelope.setConsumerSchoolId(row.getConsumerSchoolId()); + envelope.setGrantVersion(row.getGrantVersion()); + envelope.setFormUuid(row.getFormUuid()); + envelope.setFormVersion(row.getFormVersion()); + envelope.setAnswerUuid(row.getAnswerUuid()); + envelope.setAnswerRevision(row.getAnswerRevision()); + envelope.setIsDeleted(Integer.valueOf(1).equals(row.getIsDeleted())); + envelope.setContentHash(row.getContentHash()); + envelope.setOccurredAt(row.getOccurredAt() == null ? null + : row.getOccurredAt().atZone(java.time.ZoneId.of("Asia/Shanghai")).format(OCCURRED_AT)); + envelope.setSnapshot(readSnapshot(row.getSnapshotJson())); + return envelope; + } + + private Snapshot readSnapshot(String json) { + try { + return objectMapper.readValue(json, Snapshot.class); + } catch (Exception e) { + throw new IllegalStateException("投递载荷损坏(不可变行不该出现这种数据)", e); + } + } + + /** 消费方在该答卷上的当前生效订阅(只走 TASK_1 的判据,本类不拼第二套 ACL) */ + private SubscriptionGrant findLiveGrant(AnswerDelivery row) { + for (SubscriptionGrant grant : accessService.listEnabledSubscriptions(row.getFormUuid(), + row.getConsumerSchoolId())) { + if (row.getConsumerAppKey().equals(grant.getConsumerAppKey())) { + return grant; + } + } + return null; + } + + private void declareExchangeOnce(RabbitTemplate template) { + if (!exchangeDeclared.compareAndSet(false, true)) { + return; + } + try { + template.execute(channel -> { + channel.exchangeDeclare(AnswerDeliveryModels.EXCHANGE, "topic", true); + return null; + }); + } catch (Exception e) { + exchangeDeclared.set(false); + throw e; + } + } + + // ==================== confirm / return 回写 ==================== + + private void registerCallbacks() { + RabbitTemplate template = templates.getIfAvailable(); + if (template == null) { + log.warn("[v2投递] 容器内没有 RabbitTemplate,无法注册 confirm/return 回调"); + return; + } + template.setConfirmCallback((correlationData, ack, cause) -> { + if (correlationData == null || correlationData.getId() == null) { + return; // v1 广播通道不带我们的相关键 + } + try { + onBrokerConfirm(Long.valueOf(correlationData.getId()), ack, cause); + } catch (Exception e) { + log.warn("[v2投递] confirm 回写失败 id={}:{}", correlationData.getId(), e.toString()); + } + }); + template.setReturnsCallback(returned -> { + Long id = deliveryIdOf(returned.getMessage()); + if (id == null) { + return; + } + onUnroutable(id, returned.getExchange() + "/" + returned.getRoutingKey() + + " reply=" + returned.getReplyCode() + " " + returned.getReplyText()); + }); + } + + private static Long deliveryIdOf(Message message) { + if (message == null || message.getMessageProperties() == null) { + return null; + } + Object header = message.getMessageProperties().getHeaders().get(HEADER_DELIVERY_ID); + if (header == null) { + return null; + } + try { + return Long.valueOf(String.valueOf(header)); + } catch (NumberFormatException e) { + return null; + } + } + + /** confirm 只落 brokerAccepted;nack 走退避(B.4"confirm 只记 brokerAccepted") */ + void onBrokerConfirm(Long deliveryId, boolean ack, String cause) { + transactionTemplate.executeWithoutResult(tx -> { + AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null); + if (row == null) { + return; + } + if (AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState())) { + return; // 重复 confirm(broker 重投同一相关键)幂等吸收 + } + if (ack) { + row.setState(AnswerDelivery.STATE_BROKER_ACCEPTED); + row.setBrokerAcceptedAt(LocalDateTime.now()); + row.setLastError(null); + } else { + bumpRetry(row, "broker nack: " + cause); + } + deliveryRepository.save(row); + }); + } + + /** mandatory 不可路由=目标私有队列没绑上(部署契约缺隔离配置),按失败退避而不是静默丢弃 */ + void onUnroutable(Long deliveryId, String detail) { + transactionTemplate.executeWithoutResult(tx -> { + AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null); + if (row == null || AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState())) { + return; + } + markFailed(row, "不可路由(mandatory return):" + detail); + deliveryRepository.save(row); + }); + log.warn("[v2投递] 消息不可路由:id={} {}", deliveryId, detail); + } + + private void markFailed(AnswerDelivery row, String error) { + transactionTemplate.executeWithoutResult(tx -> { + AnswerDelivery fresh = deliveryRepository.findById(row.getId()).orElse(null); + if (fresh == null) { + return; + } + bumpRetry(fresh, error); + deliveryRepository.save(fresh); + }); + } + + private void markTerminal(Long id, String state, String error) { + transactionTemplate.executeWithoutResult(tx -> { + AnswerDelivery row = deliveryRepository.findById(id).orElse(null); + if (row == null) { + return; + } + row.setState(state); + row.setLastError(truncate(error)); + deliveryRepository.save(row); + }); + } + + /** 退避计数在投递行上,不在快照里:重试次数属于投递元数据,进哈希就会让同一 revision 多 hash */ + private void bumpRetry(AnswerDelivery row, String error) { + int retries = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1; + row.setRetryCount(retries); + row.setLastError(truncate(error)); + if (retries >= Math.max(1, maxRetries)) { + row.setState(AnswerDelivery.STATE_DEAD); + row.setNextRetryAt(null); + log.error("[v2投递] 有界重试耗尽→死信待处置:eventId={} consumer={} retries={} last={}", + row.getEventId(), row.getConsumerAppKey(), retries, error); + return; + } + row.setState(AnswerDelivery.STATE_FAILED); + row.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds(retries))); + } + + private long backoffSeconds(int retries) { + long base = Math.max(1L, backoffBaseSeconds); + long cap = Math.max(base, backoffCapSeconds); + long delay = base; + for (int i = 1; i < retries && delay < cap; i++) { + delay = Math.min(cap, delay * 2); + } + return delay; + } + + // ==================== 消费方回执(consumerApplied 唯一写入点) ==================== + + /** + * 幂等记录消费方应用结果:按 {@code eventId + consumerAppKey + consumerSchoolId + revision} + * 逐字复核,任一不符即拒(不许用"MQ 已确认"代替业务成功,B.6)。 + */ + @Transactional + public AppliedReportView recordApplied(AppliedReport report) { + if (report == null || isBlank(report.getEventId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "eventId 必填"); + } + AnswerDelivery row = deliveryRepository.findByEventId(report.getEventId().trim()).orElse(null); + if (row == null || !equalsText(row.getConsumerAppKey(), report.getConsumerAppKey()) + || !equalsLong(row.getConsumerSchoolId(), report.getConsumerSchoolId()) + || !equalsLong(row.getAnswerRevision(), report.getAnswerRevision()) + || !equalsText(row.getAnswerUuid(), report.getAnswerUuid())) { + throw new BizException(ErrorCode.PARAM_ERROR, "回执与投递行不匹配(eventId/消费方/revision 任一不符即拒)"); + } + String outcome = normalizeOutcome(report.getOutcome()); + AppliedReportView view = new AppliedReportView(); + view.setEventId(row.getEventId()); + view.setBrokerAcceptedAt(row.getBrokerAcceptedAt() == null ? null : TS.format(row.getBrokerAcceptedAt())); + if (row.getConsumerAppliedAt() != null) { + // 已落定:同结论=幂等重复;不同结论=冲突,保留第一次(不覆盖,与消费端 B.5 同口径) + view.setState(row.getConsumerOutcome()); + view.setConsumerAppliedAt(TS.format(row.getConsumerAppliedAt())); + view.setDuplicate(outcome.equals(row.getConsumerOutcome())); + if (!outcome.equals(row.getConsumerOutcome())) { + row.setLastError(truncate("回执结论冲突:已有 " + row.getConsumerOutcome() + ",又收到 " + outcome)); + deliveryRepository.save(row); + log.warn("[v2投递] 同一 eventId 收到不同结论(保留首次):eventId={} has={} incoming={}", + row.getEventId(), row.getConsumerOutcome(), outcome); + } + return view; + } + if (AnswerDelivery.OUTCOME_APPLIED.equals(outcome) + && !equalsText(row.getContentHash(), report.getContentHash())) { + // 自称 APPLIED 但 hash 对不上信封:按 CONFLICT 记,不把"声称成功"当成成功 + outcome = AnswerDelivery.OUTCOME_CONFLICT; + } + row.setConsumerOutcome(outcome); + row.setConsumerAppliedAt(LocalDateTime.now()); + if (report.getLocalRef() != null && !report.getLocalRef().trim().isEmpty()) { + row.setLastError(truncate("localRef=" + report.getLocalRef().trim())); + } + deliveryRepository.save(row); + view.setState(outcome); + view.setConsumerAppliedAt(TS.format(row.getConsumerAppliedAt())); + view.setDuplicate(Boolean.FALSE); + return view; + } + + private static String normalizeOutcome(String raw) { + String value = raw == null ? "" : raw.trim().toUpperCase(); + switch (value) { + case AnswerDelivery.OUTCOME_APPLIED: + case AnswerDelivery.OUTCOME_IGNORED: + case AnswerDelivery.OUTCOME_CONFLICT: + return value; + default: + throw new BizException(ErrorCode.PARAM_ERROR, "outcome 只能是 APPLIED/IGNORED/CONFLICT:" + raw); + } + } + + // ==================== TASK_3 扩展点(只调用不编辑,签名即契约) ==================== + + /** + * 授权下的重投(对账修复入口)。状态码是稳定值,TASK_3 按码分支: + *
    + *
  • {@code NO_DELIVERY_ROW}:该消费方对这份答卷从来没有投递行(从未授权或从未订阅), + * 不新建——历史 revision 的明文快照只存在于已落库的不可变行里,无行即不可重建;
  • + *
  • {@code REVISION_MISMATCH}:期望 revision 不在已有行集内(同上,附当前最大号供运维判方向);
  • + *
  • {@code GRANT_REVOKED}:当前订阅授权不生效或纪元已进位,绝不重放旧 grant(v2 §5);
  • + *
  • {@code ALREADY_DELIVERED}:消费方已回执 APPLIED,重投只会被对端幂等吸收,不做无谓发送;
  • + *
  • {@code QUEUED}:行已回到 PENDING,等下一次扫描。
  • + *
+ * + *

{@code operationId} 是调用方的操作幂等键:同一 operationId 重复调用不再刷新退避, + * 直接回 {@code QUEUED}(同一次修复动作不因重试而变成多次重投)。

+ */ + @Transactional + public DeliveryStateView replayAuthorized(ConsumerPrincipal caller, String answerUuid, + Long expectedRevision, String operationId) { + if (caller == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份"); + } + if (isBlank(answerUuid) || expectedRevision == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "answerUuid 与 expectedRevision 必填"); + } + DeliveryStateView view = new DeliveryStateView(); + view.setAnswerRevision(expectedRevision); + List rows = deliveryRepository + .findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(answerUuid.trim(), caller.getAppKey()); + if (rows.isEmpty()) { + view.setState(STATE_NO_DELIVERY_ROW); + view.setMessage("该消费方对这份答卷没有投递行(从未订阅或从未授权),历史快照不可重建"); + return view; + } + AnswerDelivery target = null; + long maxRevision = 0L; + for (AnswerDelivery row : rows) { + maxRevision = Math.max(maxRevision, row.getAnswerRevision() == null ? 0L : row.getAnswerRevision()); + if (expectedRevision.equals(row.getAnswerRevision())) { + target = row; + } + } + if (target == null) { + view.setState(STATE_REVISION_MISMATCH); + view.setMessage("期望 revision=" + expectedRevision + " 无投递行(现有最大 revision=" + maxRevision + ")"); + return view; + } + view.setEventId(target.getEventId()); + if (!grantStillLive(target)) { + view.setState(STATE_GRANT_REVOKED); + view.setMessage("订阅授权当前不生效或纪元已进位,不重放旧 grant"); + return view; + } + if (target.getConsumerAppliedAt() != null + && AnswerDelivery.OUTCOME_APPLIED.equals(target.getConsumerOutcome())) { + view.setState(STATE_ALREADY_DELIVERED); + view.setMessage("消费方已回执 APPLIED,无需重投"); + return view; + } + if (operationId != null && operationId.equals(target.getLastReplayOperationId()) + && SCANNABLE_STATES.contains(target.getState())) { + view.setState(STATE_QUEUED); + view.setMessage("同 operationId 已在队列中(幂等吸收,不刷新退避)"); + return view; + } + target.setState(AnswerDelivery.STATE_PENDING); + target.setNextRetryAt(LocalDateTime.now()); + target.setLastReplayOperationId(operationId); + deliveryRepository.save(target); + view.setState(STATE_QUEUED); + view.setMessage("已回到 PENDING,等待扫描发送"); + wakeScan(); + return view; + } + + /** 当前生效订阅复核(TASK_1 唯一判据;本类不拼第二套 ACL) */ + private boolean grantStillLive(AnswerDelivery row) { + return findLiveGrant(row) != null; + } + + // ==================== TASK_3 稳定状态码 ==================== + + public static final String STATE_NO_DELIVERY_ROW = "NO_DELIVERY_ROW"; + public static final String STATE_REVISION_MISMATCH = "REVISION_MISMATCH"; + public static final String STATE_GRANT_REVOKED = "GRANT_REVOKED"; + public static final String STATE_ALREADY_DELIVERED = "ALREADY_DELIVERED"; + public static final String STATE_QUEUED = "QUEUED"; + + /** 失败/死信分页(运维与 TASK_3 对账) */ + public FailedDeliveryPage failedPage(ConsumerPrincipal caller, Long consumerSchoolId, + Integer page, Integer size) { + if (caller == null || consumerSchoolId == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份或租户域"); + } + int pageIndex = page == null || page < 1 ? 0 : page - 1; + int pageSize = size == null || size < 1 ? 20 : Math.min(size, 100); + Page result = deliveryRepository.findFailedForConsumer(caller.getAppKey(), + consumerSchoolId, PageRequest.of(pageIndex, pageSize)); + FailedDeliveryPage out = new FailedDeliveryPage(); + out.setTotal(result.getTotalElements()); + for (AnswerDelivery row : result.getContent()) { + FailedDeliveryView item = new FailedDeliveryView(); + item.setEventId(row.getEventId()); + item.setAnswerUuid(row.getAnswerUuid()); + item.setAnswerRevision(row.getAnswerRevision()); + item.setConsumerAppKey(row.getConsumerAppKey()); + item.setConsumerSchoolId(row.getConsumerSchoolId()); + item.setState(row.getState()); + item.setRetryCount(row.getRetryCount()); + item.setLastError(row.getLastError()); + item.setNextRetryAt(row.getNextRetryAt() == null ? null : TS.format(row.getNextRetryAt())); + item.setBrokerAcceptedAt(row.getBrokerAcceptedAt() == null ? null : TS.format(row.getBrokerAcceptedAt())); + item.setConsumerAppliedAt(row.getConsumerAppliedAt() == null ? null : TS.format(row.getConsumerAppliedAt())); + out.getRows().add(item); + } + return out; + } + + // ==================== 轻量授权核验(B.6 / 撤权边界) ==================== + + /** + * 按 formUuid 批量回授权快照:不回查任何答案值文档,只回答"这个消费方在这所学校 + * 对这张表当前还能不能收订阅"。判据完全复用 TASK_1 的 {@code requireSubscribe}—— + * 拒绝(含跨校、无 grant、已撤权)统一落成 {@code subscribeAllowed=false}, + * 消费端据此停止写入并把缓存里的旧纪元作废。 + * + *

单次上限 100 张:这条接口是给消费端逐批核验用的(契约 v2 §5 撤权边界), + * 不是给每条事件回查用的——后者会把授权判定放大成热点。

+ */ + public List grantSnapshot(ConsumerPrincipal caller, Long consumerSchoolId, + List formUuids) { + if (caller == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份"); + } + if (consumerSchoolId == null || consumerSchoolId <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)"); + } + List out = new ArrayList<>(); + if (formUuids == null || formUuids.isEmpty()) { + return out; + } + String verifiedAt = TS.format(LocalDateTime.now()); + int limit = Math.min(formUuids.size(), 100); + for (int i = 0; i < limit; i++) { + String formUuid = formUuids.get(i); + if (isBlank(formUuid)) { + continue; + } + GrantSnapshotView view = new GrantSnapshotView(); + view.setFormUuid(formUuid.trim()); + view.setConsumerSchoolId(consumerSchoolId); + view.setConsumerAppKey(caller.getAppKey()); + view.setVerifiedAt(verifiedAt); + try { + SubscriptionGrant grant = accessService.requireSubscribe(caller, consumerSchoolId, + formUuid.trim()); + view.setSubscribeAllowed(Boolean.TRUE); + view.setGrantVersion(grant.getGrantVersion()); + } catch (BizException denied) { + // 跨校/无 grant/已撤权在这里本来就是同一句拒绝,对外也只给"不收"这一个事实 + view.setSubscribeAllowed(Boolean.FALSE); + view.setGrantVersion(0); + } + out.add(view); + } + return out; + } + + // ==================== 小工具 ==================== + + private static String truncate(String value) { + if (value == null) { + return null; + } + return value.length() <= 500 ? value : value.substring(0, 500); + } + + private static boolean isBlank(String raw) { + return raw == null || raw.trim().isEmpty(); + } + + private static String trim(String raw) { + return raw == null ? null : raw.trim(); + } + + private static boolean equalsText(String left, String right) { + String a = trim(left); + String b = trim(right); + return a == null ? b == null : a.equals(b); + } + + private static boolean equalsLong(Long left, Long right) { + return left == null ? right == null : left.equals(right); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java index d25ad83..897da6b 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -11,6 +11,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog; import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.model.MyModels; +import com.yunhai.formcenter.model.AnswerDeliveryModels; import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.repository.AnswerOpLogRepository; import com.yunhai.formcenter.repository.AnswerRepository; @@ -67,6 +68,8 @@ public class AnswerReadService { private final FormFileRepository fileRepository; private final AnswerOpLogRepository opLogRepository; private final FcEventPublisher events; + /** v2 快照投递:取号与 outbox 入队都发生在本类的事务内(TASK_2 B.1) */ + private final AnswerDeliveryService deliveries; // ==================== 规格构造(四路径共用) ==================== @@ -316,6 +319,8 @@ public class AnswerReadService { throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷"); } answer.setIsDeleted(1); + // 软删改变对外快照(墓碑也是变化):同事务取号 + 入队,B.1 明令不许漏这条 + deliveries.nextRevision(answer); answerRepository.save(answer); // 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填 indexService.clear(answer.getId()); @@ -325,6 +330,7 @@ public class AnswerReadService { opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)"); // 事件:tombstoned(提交人侧软删与管理面各自实体 save,共用发布器这一个出口,§6.2) events.answerTombstoned(answer); + deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_TOMBSTONED); log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId()); } @@ -364,6 +370,7 @@ public class AnswerReadService { dictCode -> dictService.allowedValues(answer.getSchId(), dictCode)); assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson()); answer.setValuesJson(valuesJson); + deliveries.nextRevision(answer); answerRepository.save(answer); // 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变 indexService.rebuild(answer, pinned.versionRow().getSchemaJson()); @@ -372,6 +379,7 @@ public class AnswerReadService { : "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim()); // 事件:updated(与管理面编辑同一点位;能走到这里说明该表未开冻结,V61 已在上面拦过) events.answerUpdated(answer); + deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED); log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(), answer.getVersion()); return toRow(answer, form); diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java new file mode 100644 index 0000000..19c1d68 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java @@ -0,0 +1,121 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties; +import com.yunhai.formcenter.model.FormSyncModels.InstructionAck; +import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.platform.openapi.core.TargetSpec; +import com.yunhai.platform.openapi.core.YschOpenCall; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; + +/** + * 对账指令派发客户端(TASK_3 改动点 3/4)。 + * + *

出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入, + * 本应用身份取 {@code yh.app-auth},密钥不进代码也不进日志。 + * 目标地址只有一个来源——{@code fc.answer.reconcile.targets} 里按已验签 consumerAppKey + * 登记的后台服务基址;请求体里、浏览器里出现的任何 URL 都不参与寻址(任务书改动点 3 末句)。

+ * + *

失败语义:本类不抛异常。派发失败对上层只是一个稳定错误码, + * 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。

+ */ +@Slf4j +@Component +public class AnswerReconcileDispatchClient { + + private final YschOpenCall openCall; + private final AnswerReconcileProperties properties; + + public AnswerReconcileDispatchClient(YschOpenCall openCall, AnswerReconcileProperties properties) { + this.openCall = openCall; + this.properties = properties; + } + + /** 出站能力是否可用(未注入 SDK 或未登记任何后台地址=不可派发,任务留在 QUEUED 不丢) */ + public boolean enabled() { + return openCall != null && properties != null && !properties.getTargets().isEmpty(); + } + + /** 派发结果的本地结论:2xx 只说明对端收下了指令,绝不映射成任务终态 */ + @Getter + @RequiredArgsConstructor + public static class DispatchOutcome { + private final boolean accepted; + private final String errorCode; + private final String message; + private final Integer attempt; + private final String state; + private final boolean duplicate; + + /** 统一构造口(服务层与规则单测都从这里造结论,不再留多个私有工厂) */ + public static DispatchOutcome of(boolean accepted, String errorCode, String message, Integer attempt, + String state, boolean duplicate) { + return new DispatchOutcome(accepted, errorCode, message, attempt, state, duplicate); + } + } + + /** 派发一条对账指令(同 taskId 重复派发由对端按 taskId 幂等复用,这里不另建任务) */ + public DispatchOutcome dispatch(ReconcileInstruction instruction) { + if (instruction == null || instruction.getConsumerAppKey() == null) { + return DispatchOutcome.of(false, AnswerReconcileService.ERR_BAD_INSTRUCTION, + "指令或消费方身份缺失", null, null, false); + } + if (!enabled()) { + return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, + "出站签名客户端不可用或 fc.answer.reconcile.targets 未登记任何后台地址", + instruction.getAttempt(), null, false); + } + TargetSpec spec = resolveTarget(instruction.getConsumerAppKey()); + if (spec == null) { + return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, + "消费方 " + instruction.getConsumerAppKey() + " 没有登记后台服务地址,不接浏览器 URL", + instruction.getAttempt(), null, false); + } + try { + InstructionAck ack = openCall.post(spec, properties.getDispatchPath(), + java.util.Collections.emptyMap(), instruction, InstructionAck.class); + if (ack == null || ack.getTaskId() == null || !ack.getTaskId().equals(instruction.getTaskId())) { + // 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 2xx) + return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, + "对端应答的 taskId 与指令不符", instruction.getAttempt(), null, false); + } + if (Boolean.FALSE.equals(ack.getAccepted())) { + return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, + "对端拒收指令:" + ack.getMessage(), ack.getAttempt(), ack.getState(), + Boolean.TRUE.equals(ack.getDuplicate())); + } + return DispatchOutcome.of(true, null, ack.getMessage(), ack.getAttempt(), ack.getState(), + Boolean.TRUE.equals(ack.getDuplicate())); + } catch (Exception e) { + log.warn("[对账] 派发失败 task={} 第 {} 次:{}", instruction.getTaskId(), + instruction.getAttempt(), e.toString()); + return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, brief(e), + instruction.getAttempt(), null, false); + } + } + + /** 登记的后台基址 → host + context-path(与 CMS/流程中心客户端同一拆解口径) */ + private TargetSpec resolveTarget(String consumerAppKey) { + String raw = properties.getTargets().get(consumerAppKey.trim()); + if (raw == null || raw.trim().isEmpty()) { + return null; + } + URI uri = URI.create(raw.trim()); + if (uri.getScheme() == null || uri.getRawAuthority() == null) { + log.warn("[对账] 登记的后台地址不是绝对 URL,忽略:appKey={}", consumerAppKey); + return null; + } + String host = uri.getScheme() + "://" + uri.getRawAuthority(); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + return TargetSpec.of(host, path); + } + + private String brief(Exception e) { + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + return message.length() > 300 ? message.substring(0, 300) : message; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileScheduler.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileScheduler.java new file mode 100644 index 0000000..30ddb7c --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileScheduler.java @@ -0,0 +1,52 @@ +package com.yunhai.formcenter.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 对账建任务的日调度(TASK_3 改动点 1)。 + * + *

拟配置时点=Asia/Shanghai 每日 02:00,默认停用:开关在 + * {@code fc.answer.reconcile.schedule-enabled},没开时这个方法一行任务都不建 + * (与 {@code FlowReconcileJob} 的"开关 + 能力闸"同形,但这里没有它的业务筛选—— + * 答卷对账扫的是订阅与清单,不是流程状态 10)。

+ * + *

闹钟本身不判授权:{@code createScheduledTasks} 只按 TASK_1 的生效订阅建任务, + * 未授权订阅一行都不产生。调度与派发是两个开关——建了任务不代表会发出去。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AnswerReconcileScheduler { + + private final AnswerReconcileService reconcileService; + + @Value("${fc.answer.reconcile.schedule-zone:Asia/Shanghai}") + private String zone = "Asia/Shanghai"; + + /** 营业日=调度触发当天的日期(进入幂等键,同一天重复触发不会叠任务) */ + @Scheduled(cron = "${fc.answer.reconcile.schedule-cron:0 0 2 * * ?}", + zone = "${fc.answer.reconcile.schedule-zone:Asia/Shanghai}") + public void createDailyTasks() { + if (!reconcileService.properties().isScheduleEnabled()) { + return; // 默认停用:未显式开闸就不产生任何对账任务 + } + LocalDate businessDate = LocalDate.now(zone()); + reconcileService.createScheduledTasks(businessDate); + } + + private ZoneId zone() { + try { + return ZoneId.of(zone == null || zone.trim().isEmpty() ? "Asia/Shanghai" : zone.trim()); + } catch (Exception e) { + log.warn("[对账] 调度时区配置不可用,按 Asia/Shanghai 取营业日:{}", zone); + return ZoneId.of("Asia/Shanghai"); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java new file mode 100644 index 0000000..0ec37e8 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileService.java @@ -0,0 +1,1262 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerReconcileManifestRow; +import com.yunhai.formcenter.entity.AnswerReconcileResult; +import com.yunhai.formcenter.entity.AnswerReconcileTask; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormConsumerGrant; +import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotEnvelope; +import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotView; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.model.FormSyncModels; +import com.yunhai.formcenter.model.FormSyncModels.AbsenceProof; +import com.yunhai.formcenter.model.FormSyncModels.ManifestPageView; +import com.yunhai.formcenter.model.FormSyncModels.ManifestRowView; +import com.yunhai.formcenter.model.FormSyncModels.ReconcileError; +import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.formcenter.model.FormSyncModels.ResultAck; +import com.yunhai.formcenter.model.FormSyncModels.ResultReport; +import com.yunhai.formcenter.model.FormSyncModels.ResultView; +import com.yunhai.formcenter.model.FormSyncModels.TaskAnswerReply; +import com.yunhai.formcenter.model.FormSyncModels.TaskCreateRequest; +import com.yunhai.formcenter.model.FormSyncModels.TaskView; +import com.yunhai.formcenter.repository.AnswerReconcileManifestRepository; +import com.yunhai.formcenter.repository.AnswerReconcileReadRepository; +import com.yunhai.formcenter.repository.AnswerReconcileResultRepository; +import com.yunhai.formcenter.repository.AnswerReconcileTaskRepository; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FormConsumerGrantRepository; +import com.yunhai.formcenter.repository.FormRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * 差异对账的中心侧门面(TASK_3,answer-reconcile-v1 全篇)。 + * + *

四类职责,边界各不越:

+ *
    + *
  1. 建任务:只认 TASK_1 的生效订阅({@code enabled=1 and subscribeAllowed=1 and readAllowed=1}), + * 按 {@code (日期, 消费方, 消费租户域, 表单)} 唯一键落一行任务;资源超限落一行 FAILED 任务留下拒绝原因, + * 绝不在请求线程里无界载表。
  2. + *
  3. 物化不可变清单:一个一致读事务内把授权范围内的 + * {@code (answerUuid, revision, hash, isDeleted)} 全部摘要写进 + * {@code answer_reconcile_manifest_row},此后分页只读这张表(§3)。
  4. + *
  5. 答复消费端:清单分页 / 当前权威快照信封 / 缺席证明 / 结果受理。四次答复每一次都重新 + * 验签身份、核对任务坐标并复核最新 grant——不因"建任务时有权"永久放行(改动点 3)。
  6. + *
  7. 派发:把指令交给已登记的后台服务地址;2xx 只写 {@code DISPATCHED}, + * 终态只能由 result 回报推进(改动点 4)。
  8. + *
+ * + *

与 TASK_2 的关系:本类只调用 {@code AnswerSnapshotService}/{@code AnswerDeliveryService}/ + * {@code FormConsumerAccessService} 这些已交付的签名入口,不改它们、不在它们里面加快捷查询, + * 也不复制任何哈希规范化实现(哈希一律经 {@code AnswerSnapshotService#contentHash})。

+ */ +@Slf4j +@Service +public class AnswerReconcileService { + + /** 稳定错误码(与实体同一集合;本类新增的 BAD_INSTRUCTION/ACTIVE_TASK_EXISTS 也在这里) */ + public static final String ERR_BAD_INSTRUCTION = "BAD_INSTRUCTION"; + public static final String ERR_TARGET_NOT_REGISTERED = AnswerReconcileTask.ERR_TARGET_NOT_REGISTERED; + public static final String ERR_DISPATCH_FAILED = AnswerReconcileTask.ERR_DISPATCH_FAILED; + public static final String ERR_LEASE_EXPIRED = AnswerReconcileTask.ERR_LEASE_EXPIRED; + public static final String ERR_GRANT_REVOKED = AnswerReconcileTask.ERR_GRANT_REVOKED; + public static final String ERR_CANCELLED = AnswerReconcileTask.ERR_CANCELLED; + public static final String ERR_TASK_NOT_FOUND = AnswerReconcileTask.ERR_TASK_NOT_FOUND; + public static final String ERR_MANIFEST_INCOMPLETE = AnswerReconcileTask.ERR_MANIFEST_INCOMPLETE; + public static final String ERR_RESOURCE_LIMIT = AnswerReconcileTask.ERR_RESOURCE_LIMIT; + public static final String ERR_SCOPE_MISMATCH = AnswerReconcileTask.ERR_SCOPE_MISMATCH; + public static final String ERR_ACTIVE_TASK_EXISTS = "ACTIVE_TASK_EXISTS"; + public static final String ERR_ANSWER_NOT_IN_SCOPE = "ANSWER_NOT_IN_SCOPE"; + public static final String ERR_EXPECTED_REVISION_REQUIRED = "EXPECTED_REVISION_REQUIRED"; + + private static final String PRESENCE_PRESENT = FormSyncModels.PRESENCE_PRESENT; + private static final String PRESENCE_ABSENT = FormSyncModels.PRESENCE_ABSENT; + private static final String PRESENCE_UNKNOWN = FormSyncModels.PRESENCE_UNKNOWN; + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + private static final int ANSWER_UUID_LEN = 32; + private static final int PROOF_ID_MAX = 128; + private static final int EVENT_ID_MAX = 36; + private static final int ERROR_DETAIL_MAX = 300; + + private final AnswerReconcileTaskRepository taskRepository; + private final AnswerReconcileManifestRepository manifestRepository; + private final AnswerReconcileResultRepository resultRepository; + private final AnswerReconcileReadRepository readRepository; + private final FormConsumerGrantRepository grantRepository; + private final FormRepository formRepository; + private final AnswerRepository answerRepository; + private final AnswerSnapshotService snapshotService; + private final FormConsumerAccessService accessService; + private final AnswerDeliveryService deliveryService; + private final ConsumerPrincipalResolver callerResolver; + private final AnswerReconcileDispatchClient dispatchClient; + private final AnswerReconcileProperties properties; + private final TransactionTemplate transactionTemplate; + private final ObjectMapper objectMapper; + /** 信封 sourceAppKey 的兜底身份(本应用签名身份);显式配置优先 */ + private final String platformAppKey; + + public AnswerReconcileService(AnswerReconcileTaskRepository taskRepository, + AnswerReconcileManifestRepository manifestRepository, + AnswerReconcileResultRepository resultRepository, + AnswerReconcileReadRepository readRepository, + FormConsumerGrantRepository grantRepository, + FormRepository formRepository, + AnswerRepository answerRepository, + AnswerSnapshotService snapshotService, + FormConsumerAccessService accessService, + AnswerDeliveryService deliveryService, + ConsumerPrincipalResolver callerResolver, + AnswerReconcileDispatchClient dispatchClient, + AnswerReconcileProperties properties, + TransactionTemplate transactionTemplate, + ObjectMapper objectMapper, + @Value("${yh.app-auth.app-key:}") String platformAppKey) { + this.taskRepository = taskRepository; + this.manifestRepository = manifestRepository; + this.resultRepository = resultRepository; + this.readRepository = readRepository; + this.grantRepository = grantRepository; + this.formRepository = formRepository; + this.answerRepository = answerRepository; + this.snapshotService = snapshotService; + this.accessService = accessService; + this.deliveryService = deliveryService; + this.callerResolver = callerResolver; + this.dispatchClient = dispatchClient; + this.properties = properties; + this.transactionTemplate = transactionTemplate; + this.objectMapper = objectMapper; + this.platformAppKey = platformAppKey; + } + + public AnswerReconcileProperties properties() { + return properties; + } + + // ==================== 1. 建任务 ==================== + + /** + * 人工建任务(管理面)。三种范围共用一条入口:单答卷 / 单表单 / 该消费方全部生效订阅。 + * + *

返回的是已落库的任务行:资源超限或范围不符时返回的是 {@code state=FAILED} 的留痕行, + * 而不是抛异常把原因吞掉——"拒绝建任务并留下拒绝原因"要能在管理面查到。

+ */ + public List createManualTasks(BoaUser user, Long domain, TaskCreateRequest request) { + if (request == null || isBlank(request.getScope())) { + throw new BizException(ErrorCode.PARAM_ERROR, "scope 必填(ANSWER / FORM / ALL)"); + } + String scope = request.getScope().trim().toUpperCase(); + String operator = operatorOf(user); + if ("ANSWER".equals(scope)) { + return Collections.singletonList(createForAnswer(domain, operator, request)); + } + if ("FORM".equals(scope)) { + return Collections.singletonList(createForForm(domain, operator, + request.getConsumerAppKey(), request.getConsumerSchoolId(), request.getFormUuid(), + AnswerReconcileTask.TRIGGER_MANUAL, manualKey(request, + request.getConsumerAppKey(), request.getConsumerSchoolId(), request.getFormUuid()))); + } + if ("ALL".equals(scope)) { + return createForAllSubscriptions(domain, operator, request); + } + throw new BizException(ErrorCode.PARAM_ERROR, "scope 只能是 ANSWER / FORM / ALL:" + scope); + } + + /** 调度建任务:按日期+订阅唯一键幂等,重复跑不出第二行;未授权订阅一条也不建 */ + public int createScheduledTasks(LocalDate businessDate) { + Long cursor = 0L; + int created = 0; + int pageSize = Math.max(1, properties.getPageSize()); + while (created < properties.getMaxTasksPerRun()) { + List grants = readRepository.findLiveSubscriptionsAfterId(cursor, + PageRequest.of(0, pageSize)); + if (grants.isEmpty()) { + break; + } + for (FormConsumerGrant grant : grants) { + cursor = grant.getId(); + String key = "D:" + businessDate + ":" + grant.getConsumerAppKey() + + ":" + grant.getConsumerSchoolId() + ":" + grant.getFormUuid(); + if (taskRepository.findByIdempotencyKey(key).isPresent()) { + continue; + } + if (taskRepository.countActiveAt(grant.getConsumerAppKey(), grant.getConsumerSchoolId(), + grant.getFormUuid()) > 0) { + continue; // 上一轮还没跑完:同一坐标不叠第二个在途任务 + } + AnswerReconcileTask task = newTask(AnswerReconcileTask.TRIGGER_SCHEDULE, null, key, + grant.getConsumerAppKey(), grant.getConsumerSchoolId(), grant.getFormUuid()); + if (task == null) { + continue; + } + created++; + if (created >= properties.getMaxTasksPerRun()) { + break; + } + } + } + log.info("[对账] 调度建任务 {} 条(营业日 {},上限 {})", created, businessDate, + properties.getMaxTasksPerRun()); + return created; + } + + private List createForAllSubscriptions(Long domain, String operator, TaskCreateRequest request) { + if (isBlank(request.getConsumerAppKey()) || request.getConsumerSchoolId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "ALL 范围必须指明 consumerAppKey 与 consumerSchoolId"); + } + requireRegisteredConsumer(request.getConsumerAppKey()); + String appKey = request.getConsumerAppKey().trim(); + Long schoolId = request.getConsumerSchoolId(); + List views = new ArrayList<>(); + Long cursor = 0L; + int pageSize = Math.max(1, properties.getPageSize()); + int made = 0; + while (made < properties.getMaxTasksPerRun()) { + List grants = readRepository.findLiveSubscriptionsAfterId(cursor, + PageRequest.of(0, pageSize)); + if (grants.isEmpty()) { + break; + } + for (FormConsumerGrant grant : grants) { + cursor = grant.getId(); + if (!appKey.equals(grant.getConsumerAppKey()) || !schoolId.equals(grant.getConsumerSchoolId())) { + continue; + } + requireSameDomain(domain, grant.getConsumerSchoolId(), grant.getFormUuid()); + String key = manualKey(request, appKey, schoolId, grant.getFormUuid()); + if (taskRepository.findByIdempotencyKey(key).isPresent()) { + continue; + } + AnswerReconcileTask task = newTask(AnswerReconcileTask.TRIGGER_MANUAL, operator, key, + appKey, schoolId, grant.getFormUuid()); + if (task != null) { + views.add(toTaskView(task)); + made++; + } + if (made >= properties.getMaxTasksPerRun()) { + break; + } + } + if (grants.size() < pageSize) { + break; + } + } + if (views.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "该消费方在该租户域没有可建任务的生效订阅(或被上限/在途任务拦下)"); + } + return views; + } + + private TaskView createForAnswer(Long domain, String operator, TaskCreateRequest request) { + if (isBlank(request.getAnswerUuid())) { + throw new BizException(ErrorCode.PARAM_ERROR, "ANSWER 范围必须指明 answerUuid"); + } + Answer answer = answerRepository.findByUuidAndSchId(request.getAnswerUuid().trim(), + request.getConsumerSchoolId()).orElse(null); + if (answer == null) { + // 跨校与不存在同口径(uuid 不可枚举,存在性本身就是探测面) + throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或不在该租户域:" + request.getAnswerUuid()); + } + String appKey = request.getConsumerAppKey(); + if (isBlank(appKey)) { + throw new BizException(ErrorCode.PARAM_ERROR, "ANSWER 范围必须指明 consumerAppKey"); + } + // 单答卷任务同样不绕授权:答卷归属校就是它的消费租户域坐标,那一域上没有生效订阅 + // 就不该建出一轮能修数据的任务(否则 ANSWER 范围会变成撤权后的后门)。 + if (liveGrant(appKey.trim(), answer.getSchId(), answer.getFormUuid()) == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + ERR_GRANT_REVOKED + ":该答卷所属表单在这个消费方与租户域上没有生效订阅,不建对账任务"); + } + requireSameDomain(domain, answer.getSchId(), answer.getFormUuid()); + AnswerReconcileTask task = newTask(AnswerReconcileTask.TRIGGER_MANUAL, operator, + manualKey(request, appKey, answer.getSchId(), answer.getFormUuid()), + appKey, answer.getSchId(), answer.getFormUuid()); + if (task == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "建任务失败:请查看管理端拒绝原因"); + } + // 单答卷任务的范围只圈这一份答卷:清单里要么有它(比摘要),要么由权威缺席答复它已不存在 + pinSingleAnswer(task, answer); + return toTaskView(task); + } + + private TaskView createForForm(Long domain, String operator, String appKey, Long schoolId, String formUuid, + String triggerType, String idempotencyKey) { + if (isBlank(appKey) || schoolId == null || isBlank(formUuid)) { + throw new BizException(ErrorCode.PARAM_ERROR, "FORM 范围必须指明 consumerAppKey/consumerSchoolId/formUuid"); + } + requireRegisteredConsumer(appKey); + requireSameDomain(domain, schoolId, formUuid.trim()); + AnswerReconcileTask task = newTask(triggerType, operator, idempotencyKey, appKey, schoolId, + formUuid.trim()); + if (task == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "建任务失败:请查看管理端拒绝原因"); + } + return toTaskView(task); + } + + /** + * 建任务的动作本体:授权、范围、并发、资源四道闸。 + * + *

任一闸拦下都会落一行 {@code FAILED} 任务(带稳定错误码与原因), + * 因为"拒绝"必须是可查的事实,而不是操作人屏幕上闪过的一行字。

+ */ + private AnswerReconcileTask newTask(String triggerType, String operator, String idempotencyKey, + String rawAppKey, Long schoolId, String formUuid) { + String appKey = rawAppKey.trim(); + Optional existing = taskRepository.findByIdempotencyKey(idempotencyKey); + if (existing.isPresent()) { + return existing.get(); + } + if (taskRepository.countActiveAt(appKey, schoolId, formUuid) > 0) { + return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid, + ERR_ACTIVE_TASK_EXISTS, "同一坐标已有在途对账任务(单并发),本轮不建第二条"); + } + FormConsumerGrant grant = liveGrant(appKey, schoolId, formUuid); + Form form = formRepository.findByUuid(formUuid).orElse(null); + if (grant == null || form == null || Integer.valueOf(1).equals(form.getIsDeleted())) { + return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid, + ERR_GRANT_REVOKED, "没有生效订阅(撤权/缺读权/缺订阅权)或表单已删:未授权订阅不产生任务"); + } + // 跨校防线:consumerSchoolId 就是答卷归属校(与投递行同口径)。TASK_2 §9#1 的 + // "调用方属于该租户域"事实源还没建,这里宁可拒绝建任务,也不能把别校数据判成本轮范围。 + if (form.getSchId() != null && !form.getSchId().equals(schoolId) + && form.getSchId() != Form.PLATFORM_SCH_ID) { + return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid, + ERR_SCOPE_MISMATCH, "订阅租户域(" + schoolId + ")与表单归属校(" + form.getSchId() + + ")不一致,跨校范围本轮拒绝(TASK_2 §9#1 未闭环前不放行)"); + } + long rows = readRepository.countScope(formUuid, schoolId); + int pageSize = Math.max(1, properties.getPageSize()); + int pages = (int) Math.ceil(rows / (double) pageSize); + if (rows > properties.getMaxRows() || pages > properties.getMaxPages()) { + return rejected(triggerType, operator, idempotencyKey, appKey, schoolId, formUuid, + ERR_RESOURCE_LIMIT, "范围内答卷 " + rows + " 行 / " + pages + " 页,超过上限(max-rows=" + + properties.getMaxRows() + ", max-pages=" + properties.getMaxPages() + + "):请缩范围或分批"); + } + AnswerReconcileTask task = new AnswerReconcileTask(); + task.setTaskUuid(newServerUuid()); + task.setConsumerAppKey(appKey); + task.setConsumerSchoolId(schoolId); + task.setFormUuid(formUuid); + task.setFormVersion(form.getCurrentVersion() == null ? 1 : form.getCurrentVersion()); + task.setTriggerType(triggerType); + task.setOperator(operator); + task.setIdempotencyKey(idempotencyKey); + task.setState(AnswerReconcileTask.STATE_QUEUED); + task.setManifestState(AnswerReconcileTask.MANIFEST_PENDING); + task.setGrantVersion(grant.getGrantVersion() == null ? 1 : grant.getGrantVersion()); + // 任务起点=建任务时刻(比物化更早):晚于此刻新增的本地行一律不可判为多余行 + task.setWindowStart(LocalDateTime.now()); + return taskRepository.save(task); + } + + private AnswerReconcileTask rejected(String triggerType, String operator, String idempotencyKey, + String appKey, Long schoolId, String formUuid, + String errorCode, String reason) { + AnswerReconcileTask task = new AnswerReconcileTask(); + task.setTaskUuid(newServerUuid()); + task.setConsumerAppKey(appKey); + task.setConsumerSchoolId(schoolId); + task.setFormUuid(formUuid); + task.setFormVersion(1); + task.setTriggerType(triggerType); + task.setOperator(operator); + task.setIdempotencyKey(idempotencyKey); + task.setState(AnswerReconcileTask.STATE_FAILED); + task.setManifestState(AnswerReconcileTask.MANIFEST_FAILED); + task.setErrorCode(errorCode); + task.setErrorDetail(truncate(reason, 500)); + task.setWindowStart(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + AnswerReconcileTask saved = taskRepository.save(task); + log.warn("[对账] 拒绝建任务 consumer={}/{} form={} 原因={}:{}", appKey, schoolId, formUuid, + errorCode, reason); + return saved; + } + + /** 单答卷任务:清单只有这一行(或零行=缺席),totalPages 因此是确定的 1 */ + private void pinSingleAnswer(AnswerReconcileTask task, Answer answer) { + transactionTemplate.executeWithoutResult(tx -> { + AnswerReconcileManifestRow row = new AnswerReconcileManifestRow(); + row.setTaskId(task.getId()); + row.setTaskUuid(task.getTaskUuid()); + row.setFormUuid(answer.getFormUuid()); + row.setFormVersion(answer.getVersion()); + row.setAnswerUuid(answer.getUuid()); + row.setRevision(answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision()); + row.setContentHash(snapshotService.contentHash(answer)); + row.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()) ? 1 : 0); + row.setSeqNo(0); + manifestRepository.save(row); + task.setManifestState(AnswerReconcileTask.MANIFEST_COMPLETE); + task.setManifestComplete(1); + task.setTotalRows(1); + task.setTotalPages(1); + task.setMaterializedRows(1); + task.setMaterializedPages(1); + task.setCheckpointCursor(answer.getUuid()); + task.setWindowEnd(LocalDateTime.now()); + taskRepository.save(task); + }); + } + + // ==================== 2. 物化不可变清单 ==================== + + /** 扫描入口:每 tick 只推一条任务(任务级单并发),异常不外抛(闹钟不能因为一次物化失败而停) */ + public int materializeNextQuietly() { + List pending = taskRepository.findPendingMaterialization(PageRequest.of(0, 1)); + if (pending.isEmpty()) { + pending = taskRepository.findStaleMaterialization(LocalDateTime.now(), PageRequest.of(0, 1)); + } + if (pending.isEmpty()) { + return 0; + } + AnswerReconcileTask task = pending.get(0); + Integer claimed = transactionTemplate.execute(tx -> + taskRepository.claimMaterialization(task.getId(), LocalDateTime.now(), + LocalDateTime.now().plusSeconds(properties.getLeaseSeconds()))); + if (claimed == null || claimed != 1) { + return 0; + } + try { + materialize(task.getId()); + return 1; + } catch (ResourceLimitException e) { + markFailed(task.getId(), ERR_RESOURCE_LIMIT, e.getMessage()); + log.warn("[对账] 物化超上限 task={}:{}", task.getTaskUuid(), e.getMessage()); + return 0; + } catch (RuntimeException e) { + markFailed(task.getId(), ERR_MANIFEST_INCOMPLETE, + "物化中断(整笔回滚,检查点随之作废):" + brief(e)); + log.warn("[对账] 物化失败 task={}:{}", task.getTaskUuid(), e.toString()); + return 0; + } + } + + /** + * 一个事务=一轮一致读:清单行、计数、结束标记同生同死。 + * + *

中途崩溃时检查点与已写行一起回滚,重启从头再物化才是正确的—— + * 半截检查点配上半截清单,会让"清单里没有"被误判成权威缺席。

+ */ + private void materialize(Long taskId) { + transactionTemplate.executeWithoutResult(tx -> { + AnswerReconcileTask task = taskRepository.findByIdForUpdate(taskId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId)); + int pageSize = Math.max(1, properties.getPageSize()); + String cursor = task.getCheckpointCursor() == null ? "" : task.getCheckpointCursor(); + int seq = Math.max(-1, manifestRepository.maxSeqNo(taskId)) + 1; + int rows = 0; + int pages = 0; + String last = null; + while (true) { + List batch = readRepository.findScopeAfterUuid(task.getFormUuid(), + task.getConsumerSchoolId(), cursor, PageRequest.of(0, pageSize)); + pages++; + if (pages > properties.getMaxPages() || rows + batch.size() > properties.getMaxRows()) { + throw new ResourceLimitException("物化过程中超过上限(已写 " + rows + " 行 / 已读 " + pages + + " 页,max-rows=" + properties.getMaxRows() + ", max-pages=" + + properties.getMaxPages() + ")"); + } + for (Answer answer : batch) { + AnswerReconcileManifestRow row = new AnswerReconcileManifestRow(); + row.setTaskId(task.getId()); + row.setTaskUuid(task.getTaskUuid()); + row.setFormUuid(task.getFormUuid()); + row.setFormVersion(answer.getVersion()); + row.setAnswerUuid(answer.getUuid()); + row.setRevision(answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision()); + row.setContentHash(snapshotService.contentHash(answer)); + row.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()) ? 1 : 0); + row.setSeqNo(seq++); + manifestRepository.save(row); + cursor = answer.getUuid(); + last = answer.getUuid(); + } + rows += batch.size(); + if (batch.size() < pageSize) { + break; // 一致读内的短页=到底;不是"某页空了" + } + } + task.setMaterializedRows(rows); + task.setMaterializedPages(pages); + task.setTotalRows(rows); + task.setTotalPages(Math.max(1, (int) Math.ceil(rows / (double) pageSize))); + task.setCheckpointCursor(last); + task.setManifestState(AnswerReconcileTask.MANIFEST_COMPLETE); + task.setManifestComplete(1); + task.setWindowEnd(LocalDateTime.now()); + task.setLeaseUntil(null); + taskRepository.save(task); + }); + } + + // ==================== 3. 派发与租约 ==================== + + public int dispatchNextQuietly() { + List due = taskRepository.findDispatchable(LocalDateTime.now(), + PageRequest.of(0, 1)); + if (due.isEmpty()) { + return 0; + } + AnswerReconcileTask candidate = due.get(0); + // 同一消费方一次只跑一轮:两个 Worker 同时扫同一份本地集合会把检查点踩成花 + if (transactionTemplate.execute(tx -> taskRepository.countInFlightForConsumer(candidate.getConsumerAppKey(), + candidate.getConsumerSchoolId(), candidate.getId())) > 0) { + return 0; + } + Integer claimed = transactionTemplate.execute(tx -> taskRepository.claimForDispatch(candidate.getId(), + LocalDateTime.now(), LocalDateTime.now().plusSeconds(properties.getLeaseSeconds()))); + if (claimed == null || claimed != 1) { + return 0; + } + AnswerReconcileTask task = taskRepository.findById(candidate.getId()).orElse(candidate); + int attempt = task.getAttempt() == null ? 1 : task.getAttempt(); + AnswerReconcileDispatchClient.DispatchOutcome outcome = dispatchClient == null + ? AnswerReconcileDispatchClient.DispatchOutcome.of(false, ERR_TARGET_NOT_REGISTERED, + "出站签名客户端未就绪", attempt, null, false) + : dispatchClient.dispatch(instructionOf(task, attempt)); + if (outcome.isAccepted()) { + log.info("[对账] 指令已被对端收下 task={} attempt={}(不代表执行完成)", task.getTaskUuid(), attempt); + return 1; + } + if (attempt >= properties.getMaxDispatchAttempts()) { + markFailed(task.getId(), outcome.getErrorCode(), + "派发连续失败到上限(第 " + attempt + " 次):" + outcome.getMessage()); + } else { + transactionTemplate.executeWithoutResult(tx -> taskRepository.rescheduleDispatch(task.getId(), + LocalDateTime.now().plusSeconds(backoffSeconds(attempt)), outcome.getErrorCode(), + truncate(outcome.getMessage(), 500))); + } + return 0; + } + + /** 租约超期:还有预算就按 attempt+1 退避重派,耗尽即 FAILED(业务结果按 taskId/attempt 幂等,不会重复计) */ + public int recoverExpiredLeasesQuietly() { + LocalDateTime now = LocalDateTime.now(); + List expired = taskRepository.findExpiredLease(now, PageRequest.of(0, 5)); + int handled = 0; + for (AnswerReconcileTask task : expired) { + int attempt = task.getAttempt() == null ? 1 : task.getAttempt(); + if (Integer.valueOf(1).equals(task.getCancelRequested())) { + transactionTemplate.executeWithoutResult(tx -> + taskRepository.markCancelled(task.getId(), now, "取消收敛:租约到期后无人回报")); + handled++; + continue; + } + if (attempt >= properties.getMaxDispatchAttempts()) { + markFailed(task.getId(), ERR_LEASE_EXPIRED, + "租约到期且派发次数到上限(第 " + attempt + " 次):对端没有回报结果,不猜它的状态"); + handled++; + continue; + } + transactionTemplate.executeWithoutResult(tx -> taskRepository.requeueExpiredLease(task.getId(), now, + now.plusSeconds(backoffSeconds(attempt)), ERR_LEASE_EXPIRED, + "租约到期,按第 " + (attempt + 1) + " 次重派(同 taskId 幂等续跑)")); + handled++; + } + return handled; + } + + private ReconcileInstruction instructionOf(AnswerReconcileTask task, int attempt) { + ReconcileInstruction instruction = new ReconcileInstruction(); + instruction.setTaskId(task.getTaskUuid()); + instruction.setAttempt(attempt); + instruction.setConsumerAppKey(task.getConsumerAppKey()); + instruction.setConsumerSchoolId(task.getConsumerSchoolId()); + instruction.setFormUuid(task.getFormUuid()); + instruction.setFormVersion(task.getFormVersion()); + instruction.setTriggerType(task.getTriggerType()); + instruction.setWindowStart(format(task.getWindowStart())); + instruction.setMaterializedAt(format(task.getWindowEnd())); + instruction.setTotalRows(task.getTotalRows()); + instruction.setTotalPages(task.getTotalPages()); + instruction.setPageSize(Math.max(1, properties.getPageSize())); + instruction.setIssuedAt(format(LocalDateTime.now())); + return instruction; + } + + private long backoffSeconds(int attempt) { + long base = Math.max(1, properties.getBackoffBaseSeconds()); + long cap = Math.max(base, properties.getBackoffCapSeconds()); + int n = Math.min(Math.max(0, attempt), 20); + long value = base * (1L << n); + return value <= 0 || value > cap ? cap : value; + } + + // ==================== 4. 消费端答复(清单 / 权威快照 / 缺席 / 结果)==================== + + /** + * 清单分页:只读本任务物化出来的不可变清单。 + * + *

{@code completed} 由"多取一条"给出,与总行数/总页数相互印证;空页不等于结束, + * 结束只有 {@code completed=true} 这一种表达。

+ */ + public ManifestPageView manifestPage(ConsumerPrincipal caller, String taskId, String cursor, Integer size) { + AnswerReconcileTask task = requireAccessible(caller, taskId); + if (!Integer.valueOf(1).equals(task.getManifestComplete())) { + throw new BizException(ErrorCode.PARAM_ERROR, ERR_MANIFEST_INCOMPLETE + ":清单尚未完整,不能开分页"); + } + int pageSize = clampPageSize(size); + String from = normalizeCursor(cursor); + List fetched = manifestRepository.findPage(task.getId(), from, + PageRequest.of(0, pageSize + 1)); + boolean completed = fetched.size() <= pageSize; + List rows = new ArrayList<>( + fetched.subList(0, Math.min(pageSize, fetched.size()))); + ManifestPageView view = new ManifestPageView(); + view.setTaskId(task.getTaskUuid()); + view.setTaskState(task.getState()); + view.setTotalRows(task.getTotalRows()); + view.setTotalPages(task.getTotalPages()); + view.setCompleted(completed); + for (AnswerReconcileManifestRow row : rows) { + ManifestRowView item = new ManifestRowView(); + item.setAnswerUuid(row.getAnswerUuid()); + item.setRevision(row.getRevision()); + item.setContentHash(row.getContentHash()); + item.setIsDeleted(Integer.valueOf(1).equals(row.getIsDeleted())); + item.setFormUuid(row.getFormUuid()); + item.setFormVersion(row.getFormVersion()); + view.getRows().add(item); + } + view.setNextCursor(completed ? null : rows.get(rows.size() - 1).getAnswerUuid()); + return view; + } + + /** + * 任务快照答复:修复入口的唯一权威来源。 + * + *

坐标全部取中心已核验的值:{@code formUuid} 来自任务行,{@code formVersion} 来自中心当前答卷行 + * (不从旧清单猜版本),{@code grantVersion} 来自刚复核的生效 grant,快照与 hash 走 + * {@code AnswerSnapshotService#currentAuthorized}(与投递行同一条 fc.canon.v1 口径)。

+ * + *

缺席只在清单完整 + 此刻中心确实没有这份答卷 + 授权新鲜三条都成立时签发证明; + * 期间新增(中心现在有了、清单里没有)只给原因,不发证明。

+ */ + public TaskAnswerReply taskAnswer(ConsumerPrincipal caller, String taskId, String answerUuid, + Long expectedRevision) { + AnswerReconcileTask task = requireAccessible(caller, taskId); + String uuid = requireText(answerUuid, "answerUuid 不能为空"); + TaskAnswerReply reply = new TaskAnswerReply(); + reply.setTaskId(task.getTaskUuid()); + reply.setConsumerAppKey(task.getConsumerAppKey()); + reply.setConsumerSchoolId(task.getConsumerSchoolId()); + reply.setFormUuid(task.getFormUuid()); + reply.setAnswerUuid(uuid); + reply.setVerifiedAt(format(LocalDateTime.now())); + reply.setGrantVersion(task.getGrantVersion()); + + Answer answer = answerRepository.findByUuidAndSchId(uuid, task.getConsumerSchoolId()).orElse(null); + if (answer != null && !task.getFormUuid().equals(answer.getFormUuid())) { + reply.setPresence(PRESENCE_UNKNOWN); + reply.setReasonCode(ERR_ANSWER_NOT_IN_SCOPE); + reply.setReason("答卷所属表单与任务范围不符,本轮不给它作权威结论"); + return reply; + } + if (answer != null) { + SnapshotView current = snapshotService.currentAuthorized(caller, task.getConsumerSchoolId(), uuid); + reply.setPresence(PRESENCE_PRESENT); + reply.setFormVersion(answer.getVersion()); + reply.setEnvelope(envelopeOf(task, current, answer)); + return reply; + } + if (!Integer.valueOf(1).equals(task.getManifestComplete())) { + reply.setPresence(PRESENCE_UNKNOWN); + reply.setReasonCode(ERR_MANIFEST_INCOMPLETE); + reply.setReason("清单尚未完整结束,任何缺席结论都不成立"); + return reply; + } + if (manifestRepository.existsByTaskIdAndAnswerUuid(task.getId(), uuid)) { + // 清单里有、当前读不到:本轮事实源仍是清单(快照读不出=授权或数据面另有原因),不发缺席证明 + reply.setPresence(PRESENCE_UNKNOWN); + reply.setReasonCode(ERR_MANIFEST_INCOMPLETE); + reply.setReason("清单内答卷当前读不到权威快照,只报告差异,不签缺席证明"); + return reply; + } + if (expectedRevision == null || expectedRevision < 1) { + reply.setPresence(PRESENCE_UNKNOWN); + reply.setReasonCode(ERR_EXPECTED_REVISION_REQUIRED); + reply.setReason("缺席证明必须绑定本地扫描到的 expectedRevision(消费端并发护栏,不是 FC 断言)"); + return reply; + } + reply.setPresence(PRESENCE_ABSENT); + reply.setAbsenceProof(absenceProof(task, uuid, expectedRevision)); + return reply; + } + + /** + * 结果受理:{@code (taskId, attempt)} 幂等,旧 attempt 不倒退新 attempt。 + * + *

身份必须等于任务目标(跨校/冒充在此拒);错误明细逐字段搬进来并截断, + * 上限之外的明细丢弃但计数照留——明细是给人看方向的,不是给日志堆值的。

+ */ + @org.springframework.transaction.annotation.Transactional + public ResultAck submitResult(ConsumerPrincipal caller, String taskId, ResultReport report) { + taskRepository.lockByTaskUuid(requireText(taskId, "taskId 必填")); + AnswerReconcileTask task = requireAccessible(caller, taskId, true); + if (report == null || report.getAttempt() == null || report.getAttempt() < 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "attempt 必填且为正整数"); + } + if (isBlank(report.getState())) { + throw new BizException(ErrorCode.PARAM_ERROR, "state 必填"); + } + int attempt = report.getAttempt(); + int dispatchedAttempt = task.getAttempt() == null ? 0 : task.getAttempt(); + if (attempt > dispatchedAttempt) { + throw new BizException(ErrorCode.FORBIDDEN, + "回报的 attempt=" + attempt + " 超过中心已派发的 " + dispatchedAttempt); + } + Optional repeat = resultRepository.findByTaskIdAndAttempt(task.getId(), attempt); + if (AnswerReconcileTask.isTerminal(task.getState()) + || (repeat.isPresent() && !AnswerReconcileTask.STATE_RUNNING.equals(repeat.get().getState()))) { + ResultAck ack = toResultAck(task); + ack.setAccepted(true); + ack.setDuplicate(true); + ack.setMessage("任务或本轮结果已终结,仅幂等确认,不恢复执行"); + return ack; + } + String reportState = normalizeReportState(report.getState()); + if (AnswerReconcileTask.STATE_COMPLETED.equals(reportState) + && (!Boolean.TRUE.equals(report.getManifestComplete()) + || !Boolean.TRUE.equals(report.getLocalScanComplete()) + || !Integer.valueOf(1).equals(task.getManifestComplete()))) { + throw new BizException(ErrorCode.PARAM_ERROR, "INCOMPLETE_RESULT:两侧扫描未完整结束,不能回报COMPLETED"); + } + Integer latest = resultRepository.maxAttempt(task.getId()); + boolean staleAttempt = attempt < dispatchedAttempt || (latest != null && attempt < latest); + if (repeat.isPresent() && AnswerReconcileTask.STATE_RUNNING.equals(reportState) && !staleAttempt) { + advanceByReport(task, report, repeat.get()); + return toResultAck(taskRepository.findById(task.getId()).orElse(task)); + } + AnswerReconcileResult row = persistResult(task, attempt, report); + ResultAck ack = toResultAck(task); + ack.setAccepted(true); + ack.setDuplicate(false); + ack.setStaleAttempt(staleAttempt); + if (staleAttempt) { + ack.setMessage("旧 attempt=" + attempt + " 的回报只留档,不推进任务状态(最新 attempt=" + latest + ")"); + return ack; + } + advanceByReport(task, report, row); + return toResultAck(taskRepository.findById(task.getId()).orElse(task)); + } + + private AnswerReconcileResult persistResult(AnswerReconcileTask task, int attempt, ResultReport report) { + List errors = sanitizeErrors(report.getErrors(), task); + String errorsJson = writeJson(errors); + while (errorsJson.length() > Math.max(2, properties.getMaxErrorChars()) && !errors.isEmpty()) { + errors.remove(errors.size() - 1); + errorsJson = writeJson(errors); // Keep valid JSON, rather than cutting in the middle of a value. + } + AnswerReconcileResult row = new AnswerReconcileResult(); + // A RUNNING progress record may advance once to a terminal result for the same attempt. + resultRepository.findByTaskIdAndAttempt(task.getId(), attempt).ifPresent(previous -> row.setId(previous.getId())); + row.setTaskId(task.getId()); + row.setTaskUuid(task.getTaskUuid()); + row.setConsumerAppKey(task.getConsumerAppKey()); + row.setConsumerSchoolId(task.getConsumerSchoolId()); + row.setFormUuid(task.getFormUuid()); + row.setAttempt(attempt); + row.setState(normalizeReportState(report.getState())); + row.setLocalScanComplete(Boolean.TRUE.equals(report.getLocalScanComplete()) ? 1 : 0); + row.setManifestComplete(Boolean.TRUE.equals(report.getManifestComplete()) ? 1 : 0); + row.setScannedRows(nonNegative(report.getScannedRows())); + row.setPagesProcessed(nonNegative(report.getPagesProcessed())); + row.setMissingRepaired(nonNegative(report.getMissingRepaired())); + row.setStaleRepaired(nonNegative(report.getStaleRepaired())); + row.setHashConflicts(nonNegative(report.getHashConflicts())); + row.setTombstonesRepaired(nonNegative(report.getTombstonesRepaired())); + row.setLocalAhead(nonNegative(report.getLocalAhead())); + row.setDeferred(nonNegative(report.getDeferred())); + row.setExtraReported(nonNegative(report.getExtraReported())); + row.setExtraRetired(nonNegative(report.getExtraRetired())); + row.setErrorCount(nonNegative(report.getErrors() == null ? 0 : report.getErrors().size())); + row.setErrorsJson(errorsJson); + row.setResumed(Boolean.TRUE.equals(report.getResumed()) ? 1 : 0); + row.setReportedAt(LocalDateTime.now()); + // Task condition updates clear the persistence context; flush the result before that happens. + return resultRepository.saveAndFlush(row); + } + + private void advanceByReport(AnswerReconcileTask task, ResultReport report, AnswerReconcileResult row) { + LocalDateTime now = LocalDateTime.now(); + String state = row.getState(); + if (AnswerReconcileTask.STATE_RUNNING.equals(state)) { + transactionTemplate.executeWithoutResult(tx -> taskRepository.renewRunning(task.getId(), row.getAttempt(), + now.plusSeconds(properties.getLeaseSeconds()))); + task.setState(AnswerReconcileTask.STATE_RUNNING); + return; + } + String errorCode = AnswerReconcileTask.STATE_FAILED.equals(state) ? "REPORTED_FAILED" : null; + String detail = AnswerReconcileTask.STATE_FAILED.equals(state) + ? "消费端回报 FAILED:见结果明细 attempt=" + row.getAttempt() : null; + transactionTemplate.executeWithoutResult(tx -> taskRepository.finishAttempt(task.getId(), row.getAttempt(), state, now, + errorCode, detail)); + task.setState(state); + } + + private String normalizeReportState(String raw) { + String state = raw.trim().toUpperCase(); + switch (state) { + case AnswerReconcileTask.STATE_QUEUED: + throw new BizException(ErrorCode.PARAM_ERROR, "回报状态不能是 QUEUED(那是中心派发前的状态)"); + case AnswerReconcileTask.STATE_COMPLETED: + case AnswerReconcileTask.STATE_PARTIAL: + case AnswerReconcileTask.STATE_FAILED: + case AnswerReconcileTask.STATE_CANCELLED: + case AnswerReconcileTask.STATE_RUNNING: + return state; + default: + throw new BizException(ErrorCode.PARAM_ERROR, + "回报状态不在闭集内(COMPLETED/PARTIAL/FAILED/CANCELLED/RUNNING):" + raw); + } + } + + /** 逐字段搬运并截断:consumer/task/range/answer 四坐标都补齐成任务口径,值文档无处进来 */ + private List sanitizeErrors(List raw, AnswerReconcileTask task) { + List out = new ArrayList<>(); + if (raw == null) { + return out; + } + int limit = Math.max(1, properties.getMaxErrorItems()); + for (ReconcileError error : raw) { + if (error == null || out.size() >= limit) { + continue; + } + ReconcileError clean = new ReconcileError(); + clean.setCode(isBlank(error.getCode()) ? "UNSPECIFIED" : error.getCode().trim().toUpperCase()); + clean.setTaskId(task.getTaskUuid()); + clean.setConsumerAppKey(task.getConsumerAppKey()); + clean.setConsumerSchoolId(task.getConsumerSchoolId()); + clean.setFormUuid(task.getFormUuid()); + clean.setAnswerUuid(isBlank(error.getAnswerUuid()) ? null : error.getAnswerUuid().trim()); + clean.setRevision(error.getRevision()); + clean.setDetail(truncate(error.getDetail(), ERROR_DETAIL_MAX)); + out.add(clean); + } + return out; + } + + private ResultAck toResultAck(AnswerReconcileTask task) { + ResultAck ack = new ResultAck(); + ack.setTaskId(task.getTaskUuid()); + ack.setAttempt(task.getAttempt()); + ack.setTaskState(task.getState()); + return ack; + } + + // ==================== 5. 管理面 ==================== + + public Page taskPage(Long domain, String appKey, Long schoolId, String state, + Integer page, Integer size) { + if (domain != null && domain > 0 && schoolId != null && !domain.equals(schoolId)) { + throw new BizException(ErrorCode.FORBIDDEN, "校级只能查看本校域的对账任务(当前域 " + domain + ")"); + } + int pageIndex = page == null || page < 1 ? 0 : page - 1; + int pageSize = size == null || size < 1 ? 20 : Math.min(size, 100); + Long scoped = domain != null && domain > 0 ? domain : schoolId; + Page rows = taskRepository.findForAdmin(isBlank(appKey) ? null : appKey.trim(), + scoped, isBlank(state) ? null : state.trim().toUpperCase(), + PageRequest.of(pageIndex, pageSize)); + List views = new ArrayList<>(); + for (AnswerReconcileTask task : rows.getContent()) { + views.add(toTaskView(task, true)); + } + return new PageImpl<>(views, rows.getPageable(), rows.getTotalElements()); + } + + public TaskView taskDetail(Long domain, String taskId) { + return toTaskView(loadTaskInDomain(domain, taskId), true); + } + + public List results(Long domain, String taskId) { + AnswerReconcileTask task = loadTaskInDomain(domain, taskId); + List views = new ArrayList<>(); + for (AnswerReconcileResult row : resultRepository.findByTask(task.getId())) { + views.add(toResultView(row)); + } + return views; + } + + public TaskView cancel(Long domain, String taskId, BoaUser user) { + AnswerReconcileTask task = loadTaskInDomain(domain, taskId); + LocalDateTime now = LocalDateTime.now(); + transactionTemplate.executeWithoutResult(tx -> taskRepository.markCancelled(task.getId(), now, + "管理面取消(操作人 " + operatorOf(user) + "):剩余投递与修复一律停")); + return toTaskView(taskRepository.findById(task.getId()).orElse(task), true); + } + + /** + * 单答卷重放(管理面→TASK_2 {@code replayAuthorized})。 + * + *

先重新 ACL 再执行:身份取任务/请求里申报的消费方,但必须①在平台密钥注册表里查得到, + * ②对该表单有当前生效的读权,③答卷就在这个租户域里——三条都在 {@code requireRead} 与 + * {@code replayAuthorized} 内部复核过,本方法只是不把它们绕开。

+ */ + public DeliveryStateView replayAnswer(BoaUser user, Long domain, String consumerAppKey, Long consumerSchoolId, + String answerUuid, Long expectedRevision, String operationId) { + requireSameDomain(domain, consumerSchoolId, null); + requireRegisteredConsumer(consumerAppKey); + if (isBlank(answerUuid) || expectedRevision == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "answerUuid 与 expectedRevision 必填"); + } + Answer answer = answerRepository.findByUuidAndSchId(answerUuid.trim(), consumerSchoolId).orElse(null); + if (answer == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或不在该租户域:" + answerUuid); + } + ConsumerPrincipal caller = new ConsumerPrincipal(consumerAppKey.trim()); + accessService.requireRead(caller, consumerSchoolId, answer.getFormUuid()); + log.info("[对账] 管理面重放 operator={} consumer={}/{} answer={} 期望 rev={}", operatorOf(user), + consumerAppKey, consumerSchoolId, answerUuid, expectedRevision); + return deliveryService.replayAuthorized(caller, answerUuid.trim(), expectedRevision, + isBlank(operationId) ? newServerUuid() : operationId.trim()); + } + + /** 投递失败分页转视图(运维入口,直接转发 TASK_2 的 failedPage,不另拼一套查询) */ + public com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage failedDeliveries( + Long domain, String consumerAppKey, Long consumerSchoolId, Integer page, Integer size) { + requireSameDomain(domain, consumerSchoolId, null); + requireRegisteredConsumer(consumerAppKey); + return deliveryService.failedPage(new ConsumerPrincipal(consumerAppKey.trim()), consumerSchoolId, + page, size); + } + + // ==================== 6. 内部:核验与装配 ==================== + + /** 每次消费端读取都要过的核验:任务存在、身份等于目标、未取消、最新 grant 仍生效且纪元未进位 */ + private AnswerReconcileTask requireAccessible(ConsumerPrincipal caller, String taskId) { + return requireAccessible(caller, taskId, false); + } + + private AnswerReconcileTask requireAccessible(ConsumerPrincipal caller, String taskId, boolean resultOnly) { + if (caller == null || isBlank(caller.getAppKey())) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份"); + } + AnswerReconcileTask task = loadTask(taskId); + if (!caller.getAppKey().trim().equals(task.getConsumerAppKey())) { + // 不是"参数不对",是冒充:别的消费方拿不到别人任务的清单与快照 + throw new BizException(ErrorCode.FORBIDDEN, "任务目标消费方与已验签身份不符"); + } + if (resultOnly && AnswerReconcileTask.isTerminal(task.getState())) { + return task; // Identity already checked; acknowledgements disclose no answer payload. + } + if (task.isCancelled()) { + throw new BizException(ErrorCode.PARAM_ERROR, ERR_CANCELLED + ":任务已取消,剩余投递与修复停止"); + } + if (AnswerReconcileTask.isTerminal(task.getState())) { + throw new BizException(ErrorCode.PARAM_ERROR, "TASK_TERMINAL:任务已终结,不再签发清单、快照或缺席证明"); + } + FormConsumerGrant grant = liveGrant(task.getConsumerAppKey(), task.getConsumerSchoolId(), + task.getFormUuid()); + if (grant == null) { + LocalDateTime now = LocalDateTime.now(); + transactionTemplate.executeWithoutResult(tx -> taskRepository.markCancelled(task.getId(), now, + "核验时发现授权已撤:取消剩余投递与修复")); + throw new BizException(ErrorCode.PARAM_ERROR, + ERR_GRANT_REVOKED + ":当前订阅授权已失效,任务已按取消处置"); + } + Integer current = grant.getGrantVersion() == null ? 1 : grant.getGrantVersion(); + if (!current.equals(task.getGrantVersion())) { + LocalDateTime now = LocalDateTime.now(); + transactionTemplate.executeWithoutResult(tx -> taskRepository.markCancelled(task.getId(), now, + "授权纪元由 " + task.getGrantVersion() + " 进位到 " + current + ":旧轮结论作废")); + throw new BizException(ErrorCode.PARAM_ERROR, + ERR_GRANT_REVOKED + ":授权纪元已进位(" + task.getGrantVersion() + "→" + current + + "),任务已按取消处置"); + } + return task; + } + + private AnswerReconcileTask loadTask(String taskId) { + AnswerReconcileTask task = isBlank(taskId) ? null + : taskRepository.findByTaskUuid(taskId.trim()).orElse(null); + if (task == null) { + throw new BizException(ErrorCode.PARAM_ERROR, ERR_TASK_NOT_FOUND + ":" + taskId); + } + return task; + } + + /** 跨域的管理面寻址一律按"任务不存在"回答(存在性差别就是探测面,与数据面同口径) */ + private AnswerReconcileTask loadTaskInDomain(Long domain, String taskId) { + AnswerReconcileTask task = loadTask(taskId); + if (domain != null && domain > 0 && !domain.equals(task.getConsumerSchoolId())) { + throw new BizException(ErrorCode.PARAM_ERROR, ERR_TASK_NOT_FOUND + ":" + taskId); + } + return task; + } + + private FormConsumerGrant liveGrant(String appKey, Long schoolId, String formUuid) { + try { + ConsumerPrincipal principal = new ConsumerPrincipal(appKey); + accessService.requireRead(principal, schoolId, formUuid); + accessService.requireSubscribe(principal, schoolId, formUuid); + } catch (BizException e) { + return null; + } + return grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(formUuid, schoolId, appKey) + .orElse(null); // Metadata only; authorization above has one shared decision point. + } + + /** 修复信封:坐标取中心已核验值,eventId 用服务端 UUID(≤36),意图块只对本人可见 */ + private SnapshotEnvelope envelopeOf(AnswerReconcileTask task, SnapshotView view, Answer answer) { + SnapshotEnvelope envelope = new SnapshotEnvelope(); + envelope.setProtocolVersion(com.yunhai.formcenter.model.AnswerDeliveryModels.PROTOCOL_VERSION); + envelope.setEventId(newServerUuid()); + envelope.setEventType(Boolean.TRUE.equals(view.getIsDeleted()) + ? com.yunhai.formcenter.model.AnswerDeliveryModels.EVENT_TOMBSTONED + : (view.getAnswerRevision() != null && view.getAnswerRevision() <= 1L + ? com.yunhai.formcenter.model.AnswerDeliveryModels.EVENT_CREATED + : com.yunhai.formcenter.model.AnswerDeliveryModels.EVENT_UPDATED)); + envelope.setSourceAppKey(sourceAppKey()); + envelope.setConsumerAppKey(task.getConsumerAppKey()); + envelope.setConsumerSchoolId(task.getConsumerSchoolId()); + envelope.setGrantVersion(task.getGrantVersion()); + envelope.setFormUuid(task.getFormUuid()); + envelope.setFormVersion(answer == null ? task.getFormVersion() : answer.getVersion()); + envelope.setAnswerUuid(view.getAnswerUuid()); + envelope.setAnswerRevision(view.getAnswerRevision()); + envelope.setIsDeleted(view.getIsDeleted()); + envelope.setContentHash(view.getContentHash()); + envelope.setOccurredAt(format(LocalDateTime.now())); + envelope.setSnapshot(view.getSnapshot()); + return envelope; + } + + private AbsenceProof absenceProof(AnswerReconcileTask task, String answerUuid, Long expectedRevision) { + AbsenceProof proof = new AbsenceProof(); + proof.setProofId(truncate("abs:" + task.getTaskUuid() + ":" + answerUuid + ":" + expectedRevision, + PROOF_ID_MAX)); + proof.setTaskId(task.getTaskUuid()); + proof.setFormUuid(task.getFormUuid()); + proof.setSchId(task.getConsumerSchoolId()); + proof.setAnswerUuid(answerUuid); + proof.setExpectedRevision(expectedRevision); + proof.setGrantVersion(task.getGrantVersion()); + proof.setIssuedAt(format(LocalDateTime.now())); + return proof; + } + + private String sourceAppKey() { + if (!isBlank(properties.getSourceAppKey())) { + return properties.getSourceAppKey().trim(); + } + return isBlank(platformAppKey) ? "" : platformAppKey.trim(); + } + + private TaskView toTaskView(AnswerReconcileTask task) { + return toTaskView(task, false); + } + + private TaskView toTaskView(AnswerReconcileTask task, boolean withLatestResult) { + TaskView view = new TaskView(); + view.setTaskId(task.getTaskUuid()); + view.setConsumerAppKey(task.getConsumerAppKey()); + view.setConsumerSchoolId(task.getConsumerSchoolId()); + view.setFormUuid(task.getFormUuid()); + view.setFormVersion(task.getFormVersion()); + view.setTriggerType(task.getTriggerType()); + view.setOperator(task.getOperator()); + view.setState(task.getState()); + view.setManifestState(task.getManifestState()); + view.setManifestComplete(Integer.valueOf(1).equals(task.getManifestComplete())); + view.setTotalRows(task.getTotalRows()); + view.setTotalPages(task.getTotalPages()); + view.setMaterializedRows(task.getMaterializedRows()); + view.setMaterializedPages(task.getMaterializedPages()); + view.setAttempt(task.getAttempt()); + view.setWindowStart(format(task.getWindowStart())); + view.setWindowEnd(format(task.getWindowEnd())); + view.setDispatchedAt(format(task.getDispatchedAt())); + view.setFinishedAt(format(task.getFinishedAt())); + view.setCancelRequested(Integer.valueOf(1).equals(task.getCancelRequested())); + view.setErrorCode(task.getErrorCode()); + view.setErrorDetail(task.getErrorDetail()); + if (withLatestResult) { + resultRepository.findFirstByTaskIdOrderByIdDesc(task.getId()) + .ifPresent(row -> view.setLatestResult(toResultView(row))); + } + return view; + } + + private ResultView toResultView(AnswerReconcileResult row) { + ResultView view = new ResultView(); + view.setTaskId(row.getTaskUuid()); + view.setAttempt(row.getAttempt()); + view.setState(row.getState()); + view.setLocalScanComplete(Integer.valueOf(1).equals(row.getLocalScanComplete())); + view.setManifestComplete(Integer.valueOf(1).equals(row.getManifestComplete())); + view.setScannedRows(row.getScannedRows()); + view.setPagesProcessed(row.getPagesProcessed()); + view.setMissingRepaired(row.getMissingRepaired()); + view.setStaleRepaired(row.getStaleRepaired()); + view.setHashConflicts(row.getHashConflicts()); + view.setTombstonesRepaired(row.getTombstonesRepaired()); + view.setLocalAhead(row.getLocalAhead()); + view.setDeferred(row.getDeferred()); + view.setExtraReported(row.getExtraReported()); + view.setExtraRetired(row.getExtraRetired()); + view.setErrorCount(row.getErrorCount()); + view.setErrors(readErrors(row.getErrorsJson())); + view.setResumed(Integer.valueOf(1).equals(row.getResumed())); + view.setReportedAt(format(row.getReportedAt())); + return view; + } + + private List readErrors(String json) { + if (isBlank(json)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(json, new TypeReference>() { + }); + } catch (Exception e) { + // 明细读不出来时宁可回空表,也不能把对账列表接口拖成 500 + log.warn("[对账] 结果明细解析失败(按空明细返回):{}", e.toString()); + return new ArrayList<>(); + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + return "[]"; + } + } + + private void markFailed(Long taskId, String errorCode, String detail) { + LocalDateTime now = LocalDateTime.now(); + transactionTemplate.executeWithoutResult(tx -> taskRepository.advanceTerminal(taskId, + AnswerReconcileTask.STATE_FAILED, now, errorCode, truncate(detail, 500))); + } + + private void requireRegisteredConsumer(String appKey) { + if (isBlank(appKey) || !callerResolver.isRegisteredAppKey(appKey)) { + throw new BizException(ErrorCode.PARAM_ERROR, "consumerAppKey 不是平台已注册应用:" + appKey); + } + } + + /** 校级管理员只能动自己域的对账;超管可跨域(与 grant 管理同一把判据) */ + private void requireSameDomain(Long domain, Long schoolId, String formUuid) { + if (schoolId == null || schoolId <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)"); + } + if (domain != null && domain > 0 && !domain.equals(schoolId)) { + throw new BizException(ErrorCode.FORBIDDEN, + "对账范围只能落在本校域(当前域 " + domain + ",目标 " + schoolId + ")"); + } + if (formUuid != null && domain != null && domain > 0) { + Form form = formRepository.findByUuid(formUuid).orElse(null); + if (form != null && form.getSchId() != null + && !form.getSchId().equals(domain) && form.getSchId() != Form.PLATFORM_SCH_ID) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid); + } + } + } + + private String operatorOf(BoaUser user) { + if (user == null) { + return "system"; + } + if (!isBlank(user.getAccount())) { + return user.getAccount().trim(); + } + return user.getId() == null ? "system" : String.valueOf(user.getId()); + } + + private int clampPageSize(Integer size) { + int defaultSize = Math.max(1, properties.getPageSize()); + return size == null || size < 1 ? defaultSize : Math.min(size, 500); + } + + private String manualKey(TaskCreateRequest request, String appKey, Long schoolId, String formUuid) { + String given = request == null ? null : request.getIdempotencyKey(); + String prefix = isBlank(given) ? newServerUuid() : given.trim(); + return truncate("M:" + prefix + ":" + appKey + ":" + schoolId + ":" + formUuid, 128); + } + + private String normalizeCursor(String cursor) { + if (cursor == null || cursor.trim().isEmpty()) { + return ""; + } + String clean = cursor.trim(); + if (clean.length() != ANSWER_UUID_LEN || !clean.matches("[a-fA-F0-9]{32}")) { + throw new BizException(ErrorCode.PARAM_ERROR, "cursor 必须是上一页返回的答卷 UUID"); + } + return clean; + } + + private String newServerUuid() { + String uuid = UUID.randomUUID().toString().replace("-", "").toLowerCase(); + return uuid.length() <= EVENT_ID_MAX ? uuid : uuid.substring(0, EVENT_ID_MAX); + } + + private static String requireText(String raw, String message) { + if (raw == null || raw.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, message); + } + return raw.trim(); + } + + private static boolean isBlank(String raw) { + return raw == null || raw.trim().isEmpty(); + } + + private static int nonNegative(Integer value) { + return value == null || value < 0 ? 0 : value; + } + + private static String truncate(String raw, int max) { + if (raw == null) { + return null; + } + return raw.length() <= max ? raw : raw.substring(0, max); + } + + private static String format(LocalDateTime value) { + return value == null ? null : TS.format(value); + } + + private static String brief(Exception e) { + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + return message.length() > 300 ? message.substring(0, 300) : message; + } + + /** 物化过程中的资源上限突破:整笔回滚,由扫描落成 FAILED+原因 */ + private static final class ResourceLimitException extends RuntimeException { + private static final long serialVersionUID = 1L; + + ResourceLimitException(String message) { + super(message); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index 2298815..41a71d6 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -8,7 +8,9 @@ import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.FillQuota; import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.FormFile; +import com.yunhai.formcenter.entity.SubmissionContext; import com.yunhai.formcenter.enums.FormEnums; +import com.yunhai.formcenter.model.AnswerDeliveryModels; import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.AnonModels; import com.yunhai.formcenter.repository.AnswerRepository; @@ -93,6 +95,10 @@ public class AnswerService { private final AnswerIndexService indexService; private final TransactionTemplate transactionTemplate; private final FcEventPublisher events; + /** v2 投递(outbox 必须在答卷事务内落库,TASK_2 B.1/B.4) */ + private final AnswerDeliveryService deliveries; + /** 可信提交上下文(hosted 链路的令牌兑换与意图原子关联,TASK_2 A.3) */ + private final SubmissionContextService contexts; /** * 提交答卷。 @@ -101,7 +107,8 @@ public class AnswerService { */ public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) { String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); - return executeSubmit(SubmitIdentity.ofSession(user), req, idempotencyKey, ANSWER_NO_RETRY); + return executeSubmit(SubmitIdentity.ofSession(user, req.getContextToken()), req, idempotencyKey, + ANSWER_NO_RETRY); } /** @@ -142,6 +149,9 @@ public class AnswerService { // 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能) Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); if (replay != null && identity.ownsReplay(replay)) { + if (identity.hosted()) { + requireHostedReplay(identity, req, replay); + } log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); return toView(replay, true); } @@ -176,10 +186,36 @@ public class AnswerService { String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() ? DEFAULT_APP_CODE : req.getAppCode().trim(); // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。 - // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56) - FormDefService.Published published = identity.anonymous() - ? defService.resolvePublishedForAnon(appCode, req.getFormCode()) - : defService.resolvePublished(sessionSchId, appCode, req.getFormCode()); + // 两条通道各用自己的解析入口,但共用 publishedOf 的生命周期判定与版本行取数(V56)。 + // hosted 是第三条取址分支,且它是"更严"的那条:坐标只认令牌里钉死的 uuid+version, + // 既不走 code 遮蔽,也绝不回落到当前发布版(TASK_2 A.3) + SubmissionContext hostedCtx = null; + FormDefService.Published published; + if (identity.hosted()) { + if (req.getSubjectId() != null && !req.getSubjectId().equals(identity.submitterId())) { + throw new BizException(ErrorCode.FORBIDDEN, "托管填报仅允许登录本人提交,不支持代填"); + } + hostedCtx = contexts.redeem(identity.contextToken(), user, sessionSchId); + contexts.assertCoordinates(hostedCtx, appCode, req.getFormCode()); + Answer alreadyLinked = contexts.linkedAnswer(hostedCtx); + if (alreadyLinked != null) { + // 同 token 同幂等键=原答卷重放(仍 200,契约 §5);换幂等键再用来交第二份=令牌复用,拒 + if (idempotencyKey.equals(alreadyLinked.getIdempotencyKey())) { + requireHostedReplay(identity, req, alreadyLinked); + log.info("[hosted] 同意图重放返回原答卷 intent={} answer={}", hostedCtx.getIntentId(), + alreadyLinked.getUuid()); + return toView(alreadyLinked, true); + } + throw new BizException(ErrorCode.CUSTOM, + "该提交上下文已完成过一次提交,不能换幂等键重复使用(请回到办事页面重新发起)"); + } + published = defService.resolvePublishedByUuid(sessionSchId, hostedCtx.getFormUuid(), + hostedCtx.getFormVersion()); + } else { + published = identity.anonymous() + ? defService.resolvePublishedForAnon(appCode, req.getFormCode()) + : defService.resolvePublished(sessionSchId, appCode, req.getFormCode()); + } Form form = published.form(); String schemaJson = published.versionRow().getSchemaJson(); Long schId = identity.anonymous() ? form.getSchId() : sessionSchId; @@ -204,6 +240,9 @@ public class AnswerService { if (!identity.ownsReplay(existing)) { throw replayConflict(identity); } + if (identity.hosted()) { + requireHostedReplay(identity, req, existing); + } return toView(existing, true); } @@ -255,6 +294,8 @@ public class AnswerService { answer.setValuesJson(valuesJson); answer.setAnswerNo(nextAnswerNo(form)); answer.setIdempotencyKey(idempotencyKey); + // 首版快照号=1(之后每次对外可见的变化由 AnswerDeliveryService.nextRevision 行级 CAS 推进) + answer.setAnswerRevision(1L); if (identity.anonymous()) { // 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据) answer.setAnonIp(identity.anon().getAnonIp()); @@ -267,13 +308,24 @@ public class AnswerService { // 9) 索引:只写 searchable 字段(V3/V9) indexService.write(answer, schemaJson); + // 9.5) 可信关联:意图 → 答卷。必须与答卷同一事务(A.3"原子关联"), + // 并且排在投递入队之前——outbox 载荷里的 intent 块就是从这里读的。 + if (hostedCtx != null) { + contexts.link(hostedCtx, answer); + } + + // 9.6) v2 快照投递入队(同事务,B.1/B.4):无订阅的简单表单不建任何投递行, + // 答卷照常提交(B.2 末句);撞同 revision 的唯一键则整事务回滚,不产生两份 hash + deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_CREATED); + // 10) 事件:created 只在"真提交"这条路上发(幂等命中在第 4 关就 return 了,不发事件)。 // 发布器内部是 afterCommit + 失败仅 warn + 未配 broker 即 no-op,主流程零风险(§6.2 裁决 2/3) events.answerCreated(answer); - log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", - identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(), - answer.getVersion(), submitterId, storedSubjectId, quotaKey); + log.info("[提交]{}{} answer={} form={}/{} v{} rev={} submitter={} subject={} quotaKey={}", + identity.anonymous() ? "[匿名]" : "", identity.hosted() ? "[hosted]" : "", answer.getUuid(), + answer.getAppCode(), answer.getCode(), answer.getVersion(), answer.getAnswerRevision(), + submitterId, storedSubjectId, quotaKey); return toView(answer, false); } @@ -438,6 +490,29 @@ public class AnswerService { return UUID.randomUUID().toString().replace("-", "").toLowerCase(); } + private void requireHostedReplay(SubmitIdentity identity, AnswerModels.SubmitRequest request, Answer answer) { + Long schoolId = FormAccessPolicy.requireSessionSchId(identity.user, "当前身份无学校归属"); + SubmissionContext context = contexts.redeem(identity.contextToken(), identity.user, schoolId); + Answer linked = contexts.linkedAnswer(context); + String app = request.getAppCode() == null || request.getAppCode().trim().isEmpty() + ? DEFAULT_APP_CODE : request.getAppCode().trim(); + contexts.assertCoordinates(context, app, request.getFormCode()); + if (linked == null || !answer.getUuid().equals(linked.getUuid()) + || !context.getFormUuid().equals(answer.getFormUuid()) + || !context.getFormVersion().equals(answer.getVersion()) + || !schoolId.equals(answer.getSchId()) + || (request.getSubjectId() != null && !request.getSubjectId().equals(identity.submitterId()))) { + throw new BizException(ErrorCode.PARAM_ERROR, "幂等键已被其他提交占用,不能跨意图复用"); + } + com.fasterxml.jackson.databind.JsonNode stored = com.yunhai.formcenter.common.FormSchemaValidator + .parse(answer.getValuesJson(), "答卷值文档损坏"); + com.fasterxml.jackson.databind.JsonNode requested = request.getValues() == null + ? com.yunhai.formcenter.common.FormSchemaValidator.mapper().createObjectNode() : request.getValues(); + if (!AnswerCanonicalizer.canonical(stored).equals(AnswerCanonicalizer.canonical(requested))) { + throw new BizException(ErrorCode.PARAM_ERROR, "同一提交上下文与幂等键不能携带不同内容重放"); + } + } + private AnswerModels.SubmitView toView(Answer answer, boolean replayed) { AnswerModels.SubmitView view = new AnswerModels.SubmitView(); view.setAnswerId(answer.getUuid()); @@ -463,19 +538,23 @@ public class AnswerService { private final BoaUser user; private final AnonModels.Context anon; private final boolean anonymous; + /** 可信提交上下文令牌(hosted 链路才有;null=中心自用的普通提交) */ + private final String contextToken; - private SubmitIdentity(BoaUser user, AnonModels.Context anon, boolean anonymous) { + private SubmitIdentity(BoaUser user, AnonModels.Context anon, boolean anonymous, String contextToken) { this.user = user; this.anon = anon; this.anonymous = anonymous; + this.contextToken = contextToken == null || contextToken.trim().isEmpty() ? null : contextToken.trim(); } - static SubmitIdentity ofSession(BoaUser user) { - return new SubmitIdentity(user, null, false); + static SubmitIdentity ofSession(BoaUser user, String contextToken) { + return new SubmitIdentity(user, null, false, contextToken); } static SubmitIdentity ofAnon(AnonModels.Context anon) { - return new SubmitIdentity(null, anon, true); + // 匿名分支永远不带令牌:hosted 首批只允许登录本人,匿名完成消息不能用于本人办件(A.3) + return new SubmitIdentity(null, anon, true, null); } boolean anonymous() { @@ -486,6 +565,14 @@ public class AnswerService { return anon; } + String contextToken() { + return contextToken; + } + + boolean hosted() { + return contextToken != null; + } + /** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */ Integer submitterId() { return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId(); diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerSnapshotService.java b/src/main/java/com/yunhai/formcenter/service/AnswerSnapshotService.java new file mode 100644 index 0000000..ebdba3d --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerSnapshotService.java @@ -0,0 +1,158 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yunhai.formcenter.common.FormSchemaValidator; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.SubmissionContext; +import com.yunhai.formcenter.model.AnswerDeliveryModels.Snapshot; +import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotView; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.SubmissionContextRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 对外答卷快照的唯一装配点(answer-delivery-v2 §2/§4,TASK_2 B.1/B.2、TASK_3 扩展点)。 + * + *

成员闭集就是契约:{@code schId/appCode/code/submitterId/subjectId/status/values/ + * answerNo/createTime/updateTime},再加一个仅对该 consumer 可见的 {@code intent} 关联块。 + * 匿名 IP/UA 是审计数据、库内 {@code id} 是内部坐标,两者都不进快照(v2 契约 §2 明文); + * 附件保持 {@code {id,fileName,key}} 元数据,永远不塞文件字节(P34 的口径)。

+ * + *

消费端不回查值文档(B.6):快照里给的就是全文,投出去即完整。因此本类的输出与 + * {@link AnswerCanonicalizer} 的哈希口径必须是同一份字节——两处都从这里取,不给分叉机会。

+ * + *

{@code intent} 块剔除出哈希(fc.canon.v1 规则⑥):同一 revision 对 A 应用带意图、 + * 对 B 应用不带,若不剔除就会出现"同版两份 hash",直接违 B.1 的不变式。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AnswerSnapshotService { + + /** + * 固定时间形态:{@code yyyy-MM-dd'T'HH:mm:ss}(秒恒出现)。 + * 不用 {@code ISO_LOCAL_DATE_TIME}——它会在秒为 0 时省掉秒段, + * 于是 {@code 10:00:00} 与 {@code 10:00} 两种字节串都合法,跨端复算的哈希就再也不能逐字比。 + */ + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + private final ObjectMapper objectMapper; + private final AnswerRepository answerRepository; + private final SubmissionContextRepository contextRepository; + private final FormConsumerAccessService accessService; + + /** 核心快照(不含 intent 关联块)——哈希按它算 */ + public Snapshot core(Answer answer) { + Snapshot snapshot = new Snapshot(); + snapshot.setSchId(answer.getSchId()); + snapshot.setAppCode(answer.getAppCode()); + snapshot.setCode(answer.getCode()); + snapshot.setSubmitterId(answer.getSubmitterId()); + snapshot.setSubjectId(answer.getSubjectId()); + snapshot.setStatus(answer.getStatus()); + snapshot.setValues(parseValues(answer)); + snapshot.setAnswerNo(answer.getAnswerNo()); + snapshot.setCreateTime(format(answer.getCreateTime())); + snapshot.setUpdateTime(format(answer.getUpdateTime())); + return snapshot; + } + + /** 带意图关联的快照(载荷与哈希分离:intent 只进载荷) */ + public Snapshot forConsumer(Answer answer, String consumerAppKey, Long consumerSchoolId) { + Snapshot snapshot = core(answer); + JsonNode intent = intentBlock(answer.getUuid(), consumerAppKey, consumerSchoolId); + if (intent != null) { + snapshot.setIntent(intent); + } + return snapshot; + } + + /** 核心快照的规范串与哈希(入队时算一次,落库不可变) */ + public String contentHash(Answer answer) { + return AnswerCanonicalizer.hashSnapshot(toJson(core(answer))); + } + + public String canonicalCore(Answer answer) { + return AnswerCanonicalizer.canonical(toJson(core(answer))); + } + + /** + * TASK_3 扩展点:授权下的当前快照读取(对账/补投用,签名消费方直取)。 + * + *

判据只有 TASK_1 那把 {@code requireRead}(读快照=读答卷数据),租户域按 + * {@code consumerSchoolId} 限定行集——平台级表单也给不出别校的答卷。 + * 返回的 revision/hash 与投递行同一条规范化口径,对账两侧可比对。

+ */ + public SnapshotView currentAuthorized(ConsumerPrincipal caller, Long consumerSchoolId, String answerUuid) { + Answer answer = answerRepository.findByUuidAndSchId(requireText(answerUuid, "answerUuid 不能为空"), + consumerSchoolId).orElse(null); + if (answer == null) { + // 不存在与跨校同一口径(uuid 不可枚举,契约 §1) + throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid); + } + accessService.requireRead(caller, consumerSchoolId, answer.getFormUuid()); + SnapshotView view = new SnapshotView(); + view.setAnswerUuid(answer.getUuid()); + view.setAnswerRevision(answer.getAnswerRevision()); + view.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted())); + view.setContentHash(contentHash(answer)); + view.setSnapshot(forConsumer(answer, caller == null ? null : caller.getAppKey(), consumerSchoolId)); + return view; + } + + /** 快照 → JSON(ObjectMapper 只做结构转换,序列化字节由 canonicalizer 定) */ + public JsonNode toJson(Snapshot snapshot) { + return objectMapper.valueToTree(snapshot); + } + + public JsonNode parseSnapshotJson(String json) { + return FormSchemaValidator.parse(json, "投递载荷损坏"); + } + + private JsonNode intentBlock(String answerUuid, String consumerAppKey, Long consumerSchoolId) { + if (consumerAppKey == null) { + return null; + } + SubmissionContext ctx = contextRepository.findByAnswerUuid(answerUuid).orElse(null); + if (ctx == null || !consumerAppKey.equals(ctx.getConsumerAppKey())) { + return null; + } + if (consumerSchoolId != null && !consumerSchoolId.equals(ctx.getConsumerSchoolId())) { + return null; + } + ObjectNode node = objectMapper.createObjectNode(); + node.put("intentId", ctx.getIntentId()); + node.put("consumerAppKey", ctx.getConsumerAppKey()); + node.put("consumerSchoolId", ctx.getConsumerSchoolId()); + node.put("submitterId", ctx.getSubmitterId()); + node.put("formVersion", ctx.getFormVersion()); + node.put("linkedRevision", ctx.getLinkedRevision()); + return node; + } + + private JsonNode parseValues(Answer answer) { + return FormSchemaValidator.parse(answer.getValuesJson() == null ? "{}" : answer.getValuesJson(), + "答卷值文档损坏:" + answer.getUuid()); + } + + private static String format(LocalDateTime time) { + return time == null ? null : TS.format(time); + } + + private static String requireText(String raw, String message) { + if (raw == null || raw.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, message); + } + return raw.trim(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/ConsumerPrincipalResolver.java b/src/main/java/com/yunhai/formcenter/service/ConsumerPrincipalResolver.java new file mode 100644 index 0000000..32aae66 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/ConsumerPrincipalResolver.java @@ -0,0 +1,72 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.appauth.AppSecretProvider; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 消费应用身份解析(v3 §1,TASK_1):把"平台验签后的 appKey"换成一个不可自报的主体对象。 + * + *

身份只有一个来源:调用方必须带 {@code X-App-Key/X-Timestamp/X-Signature} 三件套, + * 由平台 {@code AppAuthFilter} 验签通过后才把 appKey 写进请求属性 {@code yh.app.auth}。 + * 本类只转读那个属性,因此"没签名"与"签名不是我们的"在同一处就被关死—— + * 请求里另有一个自由输入的 {@code appCode},它只是资源筛选项,永远不构成身份(v3 §1)。

+ * + *

签名密钥可来自trusted-apps,但它不证明UserBase登记事实。 + * 因此另经OpenApiAppClient查询UserBase注册信息;不可达或查不到均拒绝。 + * 这里只取存在性,密钥值不出本类。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ConsumerPrincipalResolver { + + private static final int APP_KEY_MAX = 64; + + private final AppSecretProvider appSecretProvider; + + @org.springframework.beans.factory.annotation.Autowired + private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient; + + /** + * @param appKey 取自已验签的请求属性;空/超格式/注册表查不到一律 {@code UNAUTHORIZED} + */ + public ConsumerPrincipal resolveSignedCaller(String appKey) { + String clean = appKey == null ? null : appKey.trim(); + if (clean == null || clean.isEmpty() || clean.length() > APP_KEY_MAX) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的应用身份(X-App-Key 三件套)"); + } + if (appSecretProvider.getSecret(clean) == null || !isRegisteredAppKey(clean)) { + log.warn("[consumer ACL] appKey 在平台密钥注册表查不到,拒绝:appKey={}", clean); + throw new BizException(ErrorCode.UNAUTHORIZED, "已验签应用身份不可用"); + } + return new ConsumerPrincipal(clean); + } + + /** + * 管理面写入前的身份核验(v3 §2):owner/grant 里填的 appKey 必须是 UserBase 注册过的应用, + * 否则"绑一个不存在的 appKey"会把资源挂到空气上。只判存在,不回显密钥。 + */ + public boolean isRegisteredAppKey(String appKey) { + String clean = appKey == null ? null : appKey.trim(); + if (clean == null || clean.isEmpty() || clean.length() > APP_KEY_MAX) { + return false; + } + if (appRegistryClient == null || !clean.matches("[0-9]+")) { + return false; + } + try { + com.fasterxml.jackson.databind.JsonNode registered = appRegistryClient.getAppByKey(clean); + return registered != null && !registered.isNull() + && clean.equals(registered.path("appKey").asText()); + } catch (RuntimeException e) { + // Registry unavailable is not proof of registration; never fall back to trusted-apps. + log.warn("[consumer ACL] UserBase应用注册核验不可用 appKey={}", clean); + return false; + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormConsumerAccessService.java b/src/main/java/com/yunhai/formcenter/service/FormConsumerAccessService.java new file mode 100644 index 0000000..fbc48ba --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FormConsumerAccessService.java @@ -0,0 +1,401 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormConsumerGrant; +import com.yunhai.formcenter.entity.FormOwnership; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.model.FormAccessModels.CatalogItem; +import com.yunhai.formcenter.model.FormAccessModels.GrantRequest; +import com.yunhai.formcenter.model.FormAccessModels.GrantView; +import com.yunhai.formcenter.model.FormAccessModels.OwnershipView; +import com.yunhai.formcenter.model.FormAccessModels.RevokeRequest; +import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant; +import com.yunhai.formcenter.repository.FormConsumerGrantRepository; +import com.yunhai.formcenter.repository.FormOwnershipRepository; +import com.yunhai.formcenter.repository.FormRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 消费应用授权的唯一判定出口(v3 §2/§7,TASK_1)。 + * + *

三段判定顺序照 v3 §1:①调用方身份({@link ConsumerPrincipalResolver},只认验签后的 + * appKey)→ ②资源与归属(按 uuid 定位表单头表与其 owner 行)→ ③owner 或显式 grant。 + * 三段里任何一段给不出肯定答案都是同一句拒绝:跨校、缺归属、不存在、未授权在对外文案上不可区分 + * (uuid 是半公开坐标,"存在但你不配"这句话本身就是探测面,同契约 §1 的既有口径); + * 真实原因只进服务端 warn 日志。

+ * + *

读与订阅分权:owner 天然能读自己那张表的 schema/答卷(资源拥有者),但订阅 + * 必须另开一行 grant——"能看"不等于"能往外推"。这也是 TASK_2/TASK_3 唯一的授权入口, + * 它们不允许再对着仓库拼第二套规则(v3 §7)。

+ * + *

租户维度:{@code consumerSchoolId} 既是授权的生效域,也是读答卷时的行集限定域。 + * 平台级表单({@code form.sch_id=0})对某校授予后,那一校看到的答卷仍只有自己的行 + * (行集由调用侧的 {@code schId} 条件保证,本服务不放宽它)。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FormConsumerAccessService { + + private final FormOwnershipRepository ownershipRepository; + private final FormConsumerGrantRepository grantRepository; + private final FormRepository formRepository; + + // ==================== 消费面判定 ==================== + + /** + * 读授权(目录/schema/answer/answers 全走这一把,v3 §3)。 + * + * @param consumerSchoolId 调用方声明的租户域;平台级表单靠它把行集限定到那一校 + */ + public void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid) { + Form form = requireForm(formUuid); + if (caller == null || !canRead(form, consumerSchoolId, caller.getAppKey())) { + deny(caller, consumerSchoolId, form, "read"); + } + } + + /** + * 订阅授权(v3 §7):owner 不算,必须有生效且 {@code subscribe_allowed=1} 的 grant。 + * 返回带 {@code grantVersion} 的授权凭据,投递侧把它一起发出去。 + */ + public SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid) { + Form form = requireForm(formUuid); + if (caller == null || consumerSchoolId == null || consumerSchoolId <= 0 + || !isOwnDomain(form, consumerSchoolId)) { + deny(caller, consumerSchoolId, form, "subscribe"); + } + FormConsumerGrant grant = consumerSchoolId == null ? null + : grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey( + form.getUuid(), consumerSchoolId, caller == null ? null : caller.getAppKey()) + .filter(g -> isActive(g) && isOne(g.getSubscribeAllowed())) + .orElse(null); + if (grant == null) { + deny(caller, consumerSchoolId, form, "subscribe"); + } + return toSubscriptionGrant(grant); + } + + /** + * 这张表单在某校当前生效的订阅授权(TASK_3 投递目标清单,v3 §7)。 + * + *

不带调用方:事件投递发生在提交链路里,那一刻没有"调用方应用",判据只能是 + * "这张表 + 这份答卷所在校"上生效的订阅 grant。撤权后这里就查不到,后续投递自然断, + * 但已生成的任务不被改写(v3 §7)。

+ */ + public List listEnabledSubscriptions(String formUuid, Long answerSchoolId) { + List out = new ArrayList<>(); + if (formUuid == null || formUuid.trim().isEmpty() || answerSchoolId == null) { + return out; + } + Form form = formRepository.findByUuid(formUuid.trim()).orElse(null); + if (form == null || answerSchoolId <= 0 || !isOwnDomain(form, answerSchoolId)) { + return out; + } + for (FormConsumerGrant grant : grantRepository.findEnabledSubscriptions(formUuid.trim(), answerSchoolId)) { + out.add(toSubscriptionGrant(grant)); + } + return out; + } + + /** + * 目录用的批量读判定:一次取 owner 行 + 一次取该消费方在该校的生效 grant, + * 返回可读的 uuid 集(目录面对的是"这所学校的几十到几百张表",逐张判定会把 2 次查询放大成 N 次)。 + */ + public Set filterReadable(ConsumerPrincipal caller, Long consumerSchoolId, Collection
forms) { + Set readable = new HashSet<>(); + if (caller == null || consumerSchoolId == null || forms == null || forms.isEmpty()) { + return readable; + } + Map byUuid = new LinkedHashMap<>(); + for (Form form : forms) { + if (form != null && form.getUuid() != null) { + byUuid.put(form.getUuid(), form); + } + } + if (byUuid.isEmpty()) { + return readable; + } + Map ownerOf = new HashMap<>(); + for (FormOwnership ownership : ownershipRepository.findByFormUuidIn(byUuid.keySet())) { + ownerOf.put(ownership.getFormUuid(), ownership.getOwnerAppKey()); + } + Set 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 listGrants(Long domain, String formUuid) { + Form form = requireDomainForm(domain, formUuid); + List out = new ArrayList<>(); + for (FormConsumerGrant grant : grantRepository.findLiveByForm(form.getUuid())) { + out.add(toGrantView(grant)); + } + return out; + } + + /** + * 授权/重申(三元键 upsert)。 + * + *

校级管理员只能给本校授({@code consumerSchoolId} 必须等于会话域),超管可代某校授; + * 消费租户给 0 一律拒——{@code sch_id=0} 是平台级资源域,不是一个能答卷的租户。

+ */ + @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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java index 60f02db..fddf611 100644 --- a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java +++ b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java @@ -6,8 +6,12 @@ import com.yunhai.formcenter.common.FormValueSchemaBuilder; import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Form; 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.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FormRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -19,7 +23,9 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Set; /** * 一期对外读接口服务(契约 §9 两端点)。 @@ -31,8 +37,14 @@ import java.util.List; * *

与 CAS 读路径的分工:契约 §8 的行级数据范围矩阵挂在会话身份上,oapi 面没有会话用户 * (签名方是应用),因此不走 {@link AnswerScopeQueryBuilder}——它给的是"这张表的结构"与"这一条答卷", - * 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死(一期对外只读单条与 schema, - * 不提供全量拉取)。这一条写清楚,避免后来人"顺手"加一个 /oapi/form/list。

+ * 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死。这一条写清楚,避免后来人 + * "顺手"加一个不带授权的全量列表。

+ * + *

v3 起的授权变化(TASK_1,见 {@code docs/consumer-access-v3.md}):原来"应用签名通过=三个 + * 读端点全开",现在签名只确立身份,能不能读某张表另过 {@link FormConsumerAccessService#requireRead} + * (owner 或显式 read grant,默认拒绝)。带 {@code caller} 的方法才是端点入口,原不带 caller 的方法是 + * 应用内部的既有解析路径。同批新增的 {@code GET /oapi/form/forms} 是选择器目录而不是全量拉取: + * 已发布 ∩ 未软删 ∩ 调用方可读,再分页(上限 100)。

*/ @Slf4j @Service @@ -45,6 +57,8 @@ public class OapiFormService { private final FormDefService defService; private final FormDictService dictService; private final AnswerRepository answerRepository; + private final FormRepository formRepository; + private final FormConsumerAccessService accessService; /** * {@code /oapi/form/schema} 的入口分流(T11):{@code uuid} 非空走按 uuid 取版本行的新路径, @@ -95,6 +109,141 @@ public class OapiFormService { 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):read 判定先于返回任何载荷。 + * + *

uuid 形态手里就有坐标,先判读权再解析——被拒时与"这张表不存在"同一句回答, + * 不给探测面。code 形态只能先解析(消费方给的是人读 code,不是 uuid),解析出来的 + * {@code formUuid} 同样要过这一把;解析自身那句"未发布/停用"是改造前就有的既有行为, + * 本任务不改变它。

+ */ + 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} 的授权形态。 + * + *

读权被拒时对外只能说"这条答卷不存在"——答"表单不存在"等于同时确认了答卷和那张表都在, + * 那正是 v3 §2 要关死的差别信息。

+ */ + 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}):先过滤再分页,不是"分页后再筛"。 + * + *

取域是"会话声明校 ∪ 平台级(0)、已发布、未软删"(复用 {@code findPublishedInDomain}, + * 与填报入口同一把 SQL),再把可读集换成调用方的授权视图(owner ∪ 显式 read grant,批量两次查询 + * 放大成常数级),最后按 {@code appCode}/{@code keyword} 收窄、定序、切页。 + * {@code total} 是过滤后的条数——消费方的选择器要能据此判断"还有没有下一页"。

+ * + *

不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是两个都合法的可选项 + * (遮蔽规则是给按 code 的 resolve 入口用的)。{@code appCode} 只是筛选项,不参与授权判定。

+ */ + 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 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 readable = accessService.filterReadable(caller, schId, candidates); + List 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 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 + 值文档。 * 软删答卷对外不可见(与四条 CAS 读路径同一口径,V30)。 diff --git a/src/main/java/com/yunhai/formcenter/service/SubmissionContextService.java b/src/main/java/com/yunhai/formcenter/service/SubmissionContextService.java new file mode 100644 index 0000000..fe14b9f --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/SubmissionContextService.java @@ -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)。 + * + *

它把"这次填报属于那次业务意图"变成中心侧事实:意图由消费方服务端签名申请、 + * 令牌由中心签发并只存摘要、答卷在同一事务里原子关联回意图。浏览器因此永远不需要、 + * 也没办法伪造 {@code bizId/submitterId/schId}——那些坐标在签发时就钉进了 + * {@link SubmissionContext},提交时按令牌反查。

+ * + *

三条 fail-closed 规则:

+ *
    + *
  1. 授权只过 TASK_1 那把锁({@code requireRead} + {@code requireSubscribe}), + * 本类不拼第二套 ACL;跨校/未授权与"表单不存在"同一口径;
  2. + *
  3. 版本必须成对且精确:按 {@code formUuid+formVersion} 取版本行, + * 拿不到就拒——hosted 链路上"回落到当前发布版"等于把办件挂在另一张表上;
  4. + *
  5. 可嵌入 origin 与回调 URL 只从服务端配置取(按验签 appKey 查 + * {@code form-center.hosted-fill.*}),请求体里出现这两个字段也不采信。
  6. + *
+ */ +@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; + + /** + * 签发(消费方服务端签名调用)。 + * + *

同意图重试回到同一行:{@code uk_ctx_intent} 结构上不允许第二个业务意图。 + * 令牌在同一行上轮换(明文只在响应里出现一次,库里只有摘要,无法原样回吐旧令牌), + * 旧令牌随即失效——取舍是"同一意图最多只有一个可提交凭据",比"旧页面还能再交一次"安全。 + * 已关联答卷的意图不再签发:那种重试应当去读收据,而不是再拿一次填报入口。

+ */ + @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)。 + * + *

四道逐字复核:令牌存在 → 未过期 → 会话用户=断言填报人 → 会话校=上下文租户域。 + * 任一不符一律 {@code FORBIDDEN},且不给"存在但不对"与"不存在"的差别(令牌本身就是探测面)。 + * 匿名通道不会走到这里:{@code submitAnon} 没有会话用户,{@code submitterId} 对不上哨兵 0 + * ——契约里"匿名完成消息不能用于本人办件"就是被这条判据结构性挡住的。

+ */ + 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):只向所属消费应用返回, + * 且读取时再核一次 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); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9b386fe..b50a53e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -53,6 +53,16 @@ spring: # 实测把上下文启动拖到分钟级——"可选依赖"就名存实亡)。拓扑声明由 FcEventPublisher # 自己惰性做一次(exchangeDeclare durable topic,第一次真要发的时候),不依赖 admin。 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 事故): @@ -119,6 +129,36 @@ form-center: # (CAS cookie 不隔离端口,免二次登录成立的前提);同主域生产置空即整体关闭 cors: 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: level: diff --git a/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java b/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java index c25eac0..bc66b31 100644 --- a/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java +++ b/src/test/java/com/yunhai/formcenter/controller/oapi/OapiFormControllerTest.java @@ -1,23 +1,29 @@ package com.yunhai.formcenter.controller.oapi; import com.yunhai.formcenter.common.FormSchemaValidator; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; import com.yunhai.formcenter.model.OapiModels; +import com.yunhai.formcenter.service.ConsumerPrincipalResolver; import com.yunhai.formcenter.service.OapiFormService; +import com.yunhai.platform.appauth.AppAuthFilter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.web.servlet.MockMvc; 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.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; 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.status; /** - * {@code /oapi/form/schema} 的参数绑定单测(T11 W1)。 + * {@code /oapi/form/schema} 的参数绑定单测(T11 W1,v3 起带上调用方身份)。 * *

服务层的分流与口径由 {@code OapiFormServiceIT} 的 F1~F7 断言;这一层只有 MockMvc 能证三件事:

*
    @@ -26,45 +32,56 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. *
  • controller 只做透传、自己不做判断(uuid 优先与 version 单独传的拒绝规则都在服务层,F7 钉住)。
  • *
* - *

用 {@code standaloneSetup} 而不是 {@code @SpringBootTest}:{@code /oapi/*} 的验签墙与统一响应链 - * 是平台链({@code local-debug/t4-read-smoke.sh} 取证),本类只管参数形状,因此也不断言信封包装。

+ *

v3(TASK_1)加的两件:身份只从验签属性 {@code yh.app.auth} 取(属性缺失时服务层根本不被调用, + * 见 {@code unsignedRequestNeverReachesService}),以及目录端点 {@code /forms} 的分页参数绑定。 + * 真实 401 墙是平台链({@code /oapi/*} 由 {@code local-debug/t4-read-smoke.sh} 取证), + * 本类用 {@code standaloneSetup} 直接往请求上放属性,只为证"取哪一处、取不到就拒"。

*/ class OapiFormControllerTest { + private static final String CALLER_ATTR = AppAuthFilter.APP_AUTH_ATTR; + private static final ConsumerPrincipal CALLER = new ConsumerPrincipal("100018"); + private OapiFormService formService; private MockMvc mvc; @BeforeEach void setUp() { 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 @DisplayName("uuid 形态不带 code:绑定通过(不 400),code/appCode 以 null 传给服务") 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)); mvc.perform(get("/oapi/form/schema").param("schId", "1001") - .param("uuid", "t11uuid000000000000000000000000ab")) + .param("uuid", "t11uuid000000000000000000000000ab") + .requestAttr(CALLER_ATTR, "100018")) .andExpect(status().isOk()) .andExpect(jsonPath("$.formUuid").value("t11uuid000000000000000000000000ab")) .andExpect(jsonPath("$.version").value(2)); - verify(formService).schema(1001L, null, null, "t11uuid000000000000000000000000ab", null); + verify(formService).schema(CALLER, 1001L, null, null, "t11uuid000000000000000000000000ab", null); } @Test @DisplayName("uuid+version 形态:version 绑成 Integer=3;不给时是 null 而不是 0") void versionBindsAsIntegerAndDefaultsToNull() throws Exception { - when(formService.schema(1001L, null, null, "u", 3)).thenReturn(view("u", 3)); - mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u").param("version", "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") + .requestAttr(CALLER_ATTR, "100018")) .andExpect(status().isOk()) .andExpect(jsonPath("$.version").value(3)); - when(formService.schema(1001L, null, null, "u", null)).thenReturn(view("u", 7)); - mvc.perform(get("/oapi/form/schema").param("schId", "1001").param("uuid", "u")) + 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") + .requestAttr(CALLER_ATTR, "100018")) .andExpect(status().isOk()) .andExpect(jsonPath("$.version").value(7)); } @@ -72,28 +89,54 @@ class OapiFormControllerTest { @Test @DisplayName("code 形态五参全给:controller 原样透传,不做任何取舍(取舍规则在服务层)") 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") - .param("code", "honor_student").param("appCode", "ysch-flow")) + .param("code", "honor_student").param("appCode", "ysch-flow") + .requestAttr(CALLER_ATTR, "100018")) .andExpect(status().isOk()) .andExpect(jsonPath("$.groups[0].key").value("base")) .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 @DisplayName("uuid 与 code 同传:两个值都原样交给服务(uuid 优先由服务层判,见 IT F7)") 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") - .param("code", "honor_student").param("uuid", "u-first")) + .param("code", "honor_student").param("uuid", "u-first") + .requestAttr(CALLER_ATTR, "100018")) .andExpect(status().isOk()) .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) { diff --git a/src/test/java/com/yunhai/formcenter/service/FcAnswerDeliveryTest.java b/src/test/java/com/yunhai/formcenter/service/FcAnswerDeliveryTest.java new file mode 100644 index 0000000..839f370 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FcAnswerDeliveryTest.java @@ -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 段规则单测)。 + * + *

取向沿 {@code FcDiscoveryAccessTest}:不打真实库、不起 Spring 上下文,把协作者喂成桩, + * 规则一改立刻红。证据边界要说明白:本类能证明"答卷写与 outbox 写落在同一个事务回调内、 + * 回调抛出后两者都不落地、afterCommit 只唤醒扫描",但桩事务证不了 MySQL 的原子性—— + * 真库回滚属于打真库的 {@code Answer*IT} 那一层,本机无 mysqld 时不跑(交付说明"证据边界"同条)。

+ * + *

跨端夹具({@link #canonicalVectorIsByteExact()})是本契约唯一允许两端各抄一份的实现事实: + * 规范串写死在字面量里,SH 侧 {@code ShAnswerFlowTest} 钉同一个字面量, + * 任何一侧改动都会同时红在两个仓。

+ */ +@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 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 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 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 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 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 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 captureDeliveries(AnswerDeliveryRepository rows) { + ArgumentCaptor captor = ArgumentCaptor.forClass(AnswerDelivery.class); + verify(rows, atLeastOnce()).saveAndFlush(captor.capture()); + return captor.getAllValues(); + } + + private SubmissionContext captureContext(SubmissionContextRepository contexts) { + ArgumentCaptor 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); + } + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FcDiscoveryAccessTest.java b/src/test/java/com/yunhai/formcenter/service/FcDiscoveryAccessTest.java new file mode 100644 index 0000000..bce744c --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FcDiscoveryAccessTest.java @@ -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)的规则单测。 + * + *

断言的是授权规则本身,不打真实库(同 {@code FormAdminGuardTest} 的取向:把两表喂成桩, + * 规则改了立刻红)。IT 侧({@code OapiFormServiceIT})证的是载荷形状,覆盖不到"没授权的人看见什么", + * 而这一批恰恰是以拒绝为交付物的。六条判据逐条对应任务书:

+ *
    + *
  1. 身份只取验签后的 appKey:无签名、注册表查不到、以及"自报 appCode"三种都要拒;
  2. + *
  3. owner / read / subscribe 是三件事:owner 能读但不能订阅,订阅必须有显式生效 grant;
  4. + *
  5. 跨校与缺归属与不存在同一句拒绝(对外不可区分);
  6. + *
  7. 撤权后读取与订阅都失败,且 {@code grantVersion} 进位(已生成的任务不被改写,那是 TASK_3 的断言);
  8. + *
  9. 平台级表单对某校授予后,那一校之外的消费域照样读不到(租户限定不是纸面上的);
  10. + *
  11. 目录先过滤再分页:{@code total} 必须是过滤后的条数,页大小上限 100。
  12. + *
+ */ +@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 forms = new HashMap<>(); + private final Map ownerships = new HashMap<>(); + private final List 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.getArgument(0)))); + when(ownershipRepository.findByFormUuid(any())).thenAnswer(call -> + Optional.ofNullable(ownerships.get(call.getArgument(0)))); + when(ownershipRepository.findByFormUuidIn(anyCollection())).thenAnswer(call -> { + List hit = new ArrayList<>(); + for (String uuid : (Collection) 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.getArgument(1)) + && g.getConsumerAppKey().equals(call.getArgument(2))).findFirst()); + when(grantRepository.findForConsumer(anyCollection(), any(), any())).thenAnswer(call -> { + Collection uuids = call.getArgument(0); + Long schoolId = call.getArgument(1); + String appKey = call.getArgument(2); + List 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 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 schIds = call.getArgument(0); + List 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 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(); + } + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java b/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java new file mode 100644 index 0000000..49e47b0 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FcReconcileTaskTest.java @@ -0,0 +1,1093 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerReconcileManifestRow; +import com.yunhai.formcenter.entity.AnswerReconcileResult; +import com.yunhai.formcenter.entity.AnswerReconcileTask; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormConsumerGrant; +import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView; +import com.yunhai.formcenter.model.AnswerDeliveryModels.Snapshot; +import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotEnvelope; +import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotView; +import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal; +import com.yunhai.formcenter.model.FormSyncModels; +import com.yunhai.formcenter.model.FormSyncModels.ManifestPageView; +import com.yunhai.formcenter.model.FormSyncModels.ReconcileError; +import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.formcenter.model.FormSyncModels.ResultAck; +import com.yunhai.formcenter.model.FormSyncModels.ResultReport; +import com.yunhai.formcenter.model.FormSyncModels.TaskAnswerReply; +import com.yunhai.formcenter.model.FormSyncModels.TaskCreateRequest; +import com.yunhai.formcenter.model.FormSyncModels.TaskView; +import com.yunhai.formcenter.repository.AnswerReconcileManifestRepository; +import com.yunhai.formcenter.repository.AnswerReconcileReadRepository; +import com.yunhai.formcenter.repository.AnswerReconcileResultRepository; +import com.yunhai.formcenter.repository.AnswerReconcileTaskRepository; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FormConsumerGrantRepository; +import com.yunhai.formcenter.repository.FormRepository; +import com.yunhai.formcenter.service.AnswerReconcileDispatchClient.DispatchOutcome; +import com.yunhai.platform.apistd.BizException; +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.data.domain.Pageable; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +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.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 差异对账任务(TASK_3)中心侧规则单测。 + * + *

取向与 {@code FcAnswerDeliveryTest} 一致:不起 Spring 上下文、不打真实库,把协作者喂成桩, + * 规则一改立刻红。{@link #runningTransactionTemplate()} 让事务回调真的跑, + * 否则"检查点与清单行同生同死"这类断言全是空转。

+ * + *

证据边界:桩能证明"清单未完整时不签缺席证明""旧 attempt 不推进状态""超限不载表", + * 但证明不了 MySQL 的一致性读快照、{@code uk_manifest_row} 的真实冲突行为与租约条件更新在并发下的表现—— + * 那属于真库联调层,本机不起 mysqld 时不跑。

+ */ +@DisplayName("差异对账任务(中心侧)- 规则单测(TASK_3)") +class FcReconcileTaskTest { + + private static final String CONSUMER = "100018"; + private static final String OTHER_CONSUMER = "100020"; + private static final String TASK_ID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"; + private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f"; + private static final String ANSWER_A = "a0000000000000000000000000000001"; + private static final String ANSWER_B = "b0000000000000000000000000000002"; + private static final String ANSWER_C = "c0000000000000000000000000000003"; + private static final String ANSWER_Z = "z0000000000000000000000000000009"; + private static final String HASH_A = chars('a', 64); + private static final String HASH_B = chars('b', 64); + private static final String HASH_C = chars('c', 64); + private static final String HASH_D = chars('d', 64); + private static final String HASH_E = chars('e', 64); + private static final String HASH_F = chars('f', 64); + private static final long SCHOOL = 5L; + + private final ObjectMapper mapper = new ObjectMapper(); + + private AnswerReconcileTaskRepository taskRepository; + private AnswerReconcileManifestRepository manifestRepository; + private AnswerReconcileResultRepository resultRepository; + private AnswerReconcileReadRepository readRepository; + private FormConsumerGrantRepository grantRepository; + private FormRepository formRepository; + private AnswerRepository answerRepository; + private AnswerSnapshotService snapshotService; + private FormConsumerAccessService accessService; + private AnswerDeliveryService deliveryService; + private ConsumerPrincipalResolver callerResolver; + private AnswerReconcileDispatchClient dispatchClient; + private AnswerReconcileProperties properties; + private AnswerReconcileService service; + + @BeforeEach + void setUp() { + taskRepository = mock(AnswerReconcileTaskRepository.class); + manifestRepository = mock(AnswerReconcileManifestRepository.class); + resultRepository = mock(AnswerReconcileResultRepository.class); + readRepository = mock(AnswerReconcileReadRepository.class); + grantRepository = mock(FormConsumerGrantRepository.class); + formRepository = mock(FormRepository.class); + answerRepository = mock(AnswerRepository.class); + snapshotService = mock(AnswerSnapshotService.class); + accessService = mock(FormConsumerAccessService.class); + deliveryService = mock(AnswerDeliveryService.class); + callerResolver = mock(ConsumerPrincipalResolver.class); + dispatchClient = mock(AnswerReconcileDispatchClient.class); + properties = new AnswerReconcileProperties(); + properties.setPageSize(2); + properties.setMaxRows(10); + properties.setMaxPages(3); + properties.setMaxTasksPerRun(5); + properties.setMaxDispatchAttempts(3); + properties.setLeaseSeconds(900); + properties.setSourceAppKey("100021"); + // 任务行的 save 默认把入参回给你(并补 id),否则拒绝留痕行拿不到 id + when(taskRepository.save(any(AnswerReconcileTask.class))).thenAnswer(call -> { + AnswerReconcileTask argument = call.getArgument(0); + if (argument.getId() == null) { + argument.setId(1L); + } + return argument; + }); + service = new AnswerReconcileService(taskRepository, manifestRepository, resultRepository, readRepository, + grantRepository, formRepository, answerRepository, snapshotService, accessService, + deliveryService, callerResolver, dispatchClient, properties, runningTransactionTemplate(), + mapper, CONSUMER); + } + + // ==================== 建任务 ==================== + + @Test + @DisplayName("调度按『日期+订阅+范围』唯一键建任务:同一天重复触发不出第二行") + void scheduledTaskCreationIsIdempotentPerDay() { + FormConsumerGrant grant = grant(1L, FORM_UUID, 1); + when(readRepository.findLiveSubscriptionsAfterId(eq(0L), any(Pageable.class))) + .thenReturn(Collections.singletonList(grant)); + when(readRepository.findLiveSubscriptionsAfterId(eq(1L), any(Pageable.class))) + .thenReturn(Collections.emptyList()); + when(taskRepository.findByIdempotencyKey(anyString())).thenReturn(Optional.empty()); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + when(readRepository.countScope(FORM_UUID, SCHOOL)).thenReturn(2L); + when(formRepository.findByUuid(FORM_UUID)).thenReturn(Optional.of(form(SCHOOL))); + + assertThat(service.createScheduledTasks(LocalDate.of(2026, 10, 21))).isEqualTo(1); + + ArgumentCaptor saved = ArgumentCaptor.forClass(AnswerReconcileTask.class); + verify(taskRepository).save(saved.capture()); + assertThat(saved.getValue().getIdempotencyKey()) + .isEqualTo("D:2026-10-21:" + CONSUMER + ":" + SCHOOL + ":" + FORM_UUID); + assertThat(saved.getValue().getTriggerType()).isEqualTo(AnswerReconcileTask.TRIGGER_SCHEDULE); + assertThat(saved.getValue().getState()).isEqualTo(AnswerReconcileTask.STATE_QUEUED); + assertThat(saved.getValue().getManifestState()).isEqualTo(AnswerReconcileTask.MANIFEST_PENDING); + + reset(taskRepository); + when(taskRepository.findByIdempotencyKey(anyString())) + .thenReturn(Optional.of(task(AnswerReconcileTask.STATE_QUEUED))); + when(readRepository.findLiveSubscriptionsAfterId(eq(0L), any(Pageable.class))) + .thenReturn(Collections.singletonList(grant)); + when(readRepository.findLiveSubscriptionsAfterId(eq(1L), any(Pageable.class))) + .thenReturn(Collections.emptyList()); + + assertThat(service.createScheduledTasks(LocalDate.of(2026, 10, 21))).isZero(); + verify(taskRepository, never()).save(any(AnswerReconcileTask.class)); + } + + @Test + @DisplayName("未授权订阅不产生可执行任务:落成带原因的拒绝留痕,且不载表") + void revokedSubscriptionCreatesNoExecutableTask() { + when(callerResolver.isRegisteredAppKey(CONSUMER)).thenReturn(true); + when(taskRepository.findByIdempotencyKey(anyString())).thenReturn(Optional.empty()); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.empty()); + when(formRepository.findByUuid(FORM_UUID)).thenReturn(Optional.of(form(SCHOOL))); + + TaskView view = service.createManualTasks(admin(), 0L, formRequest(null)).get(0); + + assertThat(view.getState()).isEqualTo(AnswerReconcileTask.STATE_FAILED); + assertThat(view.getManifestState()).isEqualTo(AnswerReconcileTask.MANIFEST_FAILED); + assertThat(view.getErrorCode()).isEqualTo(AnswerReconcileService.ERR_GRANT_REVOKED); + assertThat(view.getErrorDetail()).contains("未授权订阅不产生任务"); + verify(readRepository, never()).findScopeAfterUuid(anyString(), anyLong(), anyString(), any()); + verify(manifestRepository, never()).save(any(AnswerReconcileManifestRow.class)); + } + + @Test + @DisplayName("超过资源上限拒绝建任务并留下原因,不当场无界全表载入内存") + void resourceLimitRefusesTaskCreationWithoutLoadingTable() { + stubHappyCoord(); + when(readRepository.countScope(FORM_UUID, SCHOOL)).thenReturn(9999L); + + TaskView view = service.createManualTasks(admin(), 0L, formRequest(null)).get(0); + + assertThat(view.getState()).isEqualTo(AnswerReconcileTask.STATE_FAILED); + assertThat(view.getErrorCode()).isEqualTo(AnswerReconcileService.ERR_RESOURCE_LIMIT); + assertThat(view.getErrorDetail()).contains("max-rows=10"); + verify(readRepository, never()).findScopeAfterUuid(anyString(), anyLong(), anyString(), any()); + verify(manifestRepository, never()).save(any(AnswerReconcileManifestRow.class)); + } + + @Test + @DisplayName("同一坐标已有在途任务时不再建第二条(单并发)") + void activeTaskBlocksSecondTaskAtSameCoordinate() { + when(callerResolver.isRegisteredAppKey(CONSUMER)).thenReturn(true); + when(taskRepository.findByIdempotencyKey(anyString())).thenReturn(Optional.empty()); + when(taskRepository.countActiveAt(CONSUMER, SCHOOL, FORM_UUID)).thenReturn(1L); + + TaskView view = service.createManualTasks(admin(), 0L, formRequest("op-1")).get(0); + + assertThat(view.getErrorCode()).isEqualTo(AnswerReconcileService.ERR_ACTIVE_TASK_EXISTS); + verify(readRepository, never()).countScope(anyString(), anyLong()); + } + + @Test + @DisplayName("订阅租户域与表单归属校不一致时拒绝(跨校事实源未闭环前不放行)") + void crossSchoolScopeIsRefused() { + stubHappyCoord(); + when(formRepository.findByUuid(FORM_UUID)).thenReturn(Optional.of(form(6L))); + + TaskView view = service.createManualTasks(admin(), 0L, formRequest(null)).get(0); + + assertThat(view.getErrorCode()).isEqualTo(AnswerReconcileService.ERR_SCOPE_MISMATCH); + } + + @Test + @DisplayName("全量人工触发按 maxTasksPerRun 裂变上限") + void fanOutHonoursMaxTasksPerRun() { + properties.setMaxTasksPerRun(2); + when(callerResolver.isRegisteredAppKey(CONSUMER)).thenReturn(true); + when(taskRepository.findByIdempotencyKey(anyString())).thenReturn(Optional.empty()); + FormConsumerGrant first = grant(1L, FORM_UUID, 1); + FormConsumerGrant second = grant(2L, "10000000000000000000000000000002", 1); + FormConsumerGrant third = grant(3L, "10000000000000000000000000000003", 1); + when(readRepository.findLiveSubscriptionsAfterId(eq(0L), any(Pageable.class))) + .thenReturn(Arrays.asList(first, second, third)); + when(formRepository.findByUuid(anyString())).thenReturn(Optional.of(form(SCHOOL))); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(anyString(), eq(SCHOOL), + eq(CONSUMER))).thenReturn(Optional.of(first)); + when(readRepository.countScope(anyString(), anyLong())).thenReturn(1L); + + TaskCreateRequest request = new TaskCreateRequest(); + request.setScope("ALL"); + request.setConsumerAppKey(CONSUMER); + request.setConsumerSchoolId(SCHOOL); + request.setIdempotencyKey("bulk-1"); + List views = service.createManualTasks(admin(), 0L, request); + + assertThat(views).hasSize(2); + verify(taskRepository, times(2)).save(any(AnswerReconcileTask.class)); + } + + // ==================== 不可变清单物化 ==================== + + @Test + @DisplayName("物化把授权范围内四元摘要含墓碑写进不可变清单,hash 走 TASK_2 同一实现") + void materializationWritesImmutableManifestIncludingTombstones() { + AnswerReconcileTask task = freshQueuedTask(); + when(taskRepository.findPendingMaterialization(any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(taskRepository.claimMaterialization(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(1); + when(taskRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(task)); + when(manifestRepository.maxSeqNo(1L)).thenReturn(-1); + Answer live = answer(ANSWER_A, 3L, 0); + Answer tomb = answer(ANSWER_B, 4L, 1); + when(readRepository.findScopeAfterUuid(eq(FORM_UUID), eq(SCHOOL), anyString(), any(Pageable.class))) + .thenAnswer(call -> call.getArgument(2, String.class).isEmpty() + ? Arrays.asList(live, tomb) : Collections.emptyList()); + when(snapshotService.contentHash(any(Answer.class))).thenAnswer(call -> + call.getArgument(0, Answer.class) == live ? HASH_A : HASH_B); + + assertThat(service.materializeNextQuietly()).isEqualTo(1); + + ArgumentCaptor rows = ArgumentCaptor.forClass(AnswerReconcileManifestRow.class); + verify(manifestRepository, times(2)).save(rows.capture()); + List written = rows.getAllValues(); + assertThat(written).extracting(AnswerReconcileManifestRow::getAnswerUuid) + .containsExactly(ANSWER_A, ANSWER_B); + assertThat(written.get(0).getContentHash()).isEqualTo(HASH_A); + assertThat(written.get(1).getIsDeleted()).isEqualTo(1); + assertThat(written.get(0).getRevision()).isEqualTo(3L); + assertThat(written.get(1).getRevision()).isEqualTo(4L); + assertThat(written.get(0).getFormVersion()).isEqualTo(7); + assertThat(task.getManifestComplete()).isEqualTo(1); + assertThat(task.getManifestState()).isEqualTo(AnswerReconcileTask.MANIFEST_COMPLETE); + assertThat(task.getTotalRows()).isEqualTo(2); + assertThat(task.getTotalPages()).isEqualTo(1); + assertThat(task.getCheckpointCursor()).isEqualTo(ANSWER_B); + verify(snapshotService, times(2)).contentHash(any(Answer.class)); + } + + @Test + @DisplayName("物化途中越页数上限:任务落 FAILED 带原因,清单结束标记不置真") + void materializationAbortsWithResourceLimitAndKeepsManifestIncomplete() { + AnswerReconcileTask task = freshQueuedTask(); + when(taskRepository.findPendingMaterialization(any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(taskRepository.claimMaterialization(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(1); + when(taskRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(task)); + when(manifestRepository.maxSeqNo(1L)).thenReturn(-1); + when(readRepository.findScopeAfterUuid(eq(FORM_UUID), eq(SCHOOL), anyString(), any(Pageable.class))) + .thenReturn(Arrays.asList(answer(ANSWER_A, 1L, 0), answer(ANSWER_B, 1L, 0))); + when(snapshotService.contentHash(any(Answer.class))).thenReturn(HASH_C); + + assertThat(service.materializeNextQuietly()).isZero(); + + ArgumentCaptor codes = ArgumentCaptor.forClass(String.class); + verify(taskRepository, atLeastOnce()).advanceTerminal(eq(1L), + eq(AnswerReconcileTask.STATE_FAILED), any(LocalDateTime.class), codes.capture(), any()); + assertThat(codes.getAllValues()).contains(AnswerReconcileService.ERR_RESOURCE_LIMIT); + assertThat(task.getManifestComplete()).isZero(); + assertThat(task.getManifestState()).isEqualTo(AnswerReconcileTask.MANIFEST_PENDING); + } + + // ==================== 清单分页(UUID 游标)==================== + + @Test + @DisplayName("清单分页:多取一条给 nextCursor/completed,空页不等于结束") + void manifestPageUsesUuidCursorAndExplicitEndMarker() { + stubAccessibleTask(); + when(manifestRepository.findPage(eq(1L), anyString(), any(Pageable.class))) + .thenReturn(Arrays.asList(manifestRow(ANSWER_A, 3L, HASH_A, 0), + manifestRow(ANSWER_B, 4L, HASH_B, 1), manifestRow(ANSWER_C, 1L, HASH_C, 0))); + + ManifestPageView page = service.manifestPage(caller(CONSUMER), TASK_ID, null, 2); + + assertThat(page.getRows()).hasSize(2); + assertThat(page.getCompleted()).isFalse(); + assertThat(page.getNextCursor()).isEqualTo(ANSWER_B); + assertThat(page.getRows().get(1).getIsDeleted()).isTrue(); + assertThat(page.getTotalRows()).isEqualTo(2); + assertThat(page.getTaskState()).isEqualTo(AnswerReconcileTask.STATE_QUEUED); + } + + @Test + @DisplayName("整页恰好到底时 completed=true 且 nextCursor=null(不以空页作结束判据)") + void manifestPageLastPageCompletes() { + stubAccessibleTask(); + when(manifestRepository.findPage(eq(1L), anyString(), any(Pageable.class))) + .thenReturn(Collections.singletonList(manifestRow(ANSWER_A, 3L, HASH_A, 0))); + + ManifestPageView page = service.manifestPage(caller(CONSUMER), TASK_ID, ANSWER_B, 2); + + assertThat(page.getCompleted()).isTrue(); + assertThat(page.getNextCursor()).isNull(); + } + + @Test + @DisplayName("游标只认 32 位十六进制 UUID:页码与库内 id 都是非法输入") + void manifestRejectsNonUuidCursor() { + stubAccessibleTask(); + assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, "2", 2)) + .isInstanceOf(BizException.class).hasMessageContaining("答卷 UUID"); + assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, ANSWER_Z, 2)) + .isInstanceOf(BizException.class).hasMessageContaining("答卷 UUID"); + } + + @Test + @DisplayName("清单未完整结束前不给分页:不能用『某页空了』伪装完整性") + void manifestPageRefusedBeforeCompletion() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + task.setManifestComplete(0); + task.setManifestState(AnswerReconcileTask.MANIFEST_PENDING); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + + assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, null, 2)) + .isInstanceOf(BizException.class) + .hasMessageContaining(AnswerReconcileService.ERR_MANIFEST_INCOMPLETE); + } + + // ==================== 权威快照与缺席证明 ==================== + + @Test + @DisplayName("任务快照答复:信封坐标取中心已核验值,版本来自当前答卷行而非旧清单") + void taskAnswerEnvelopeUsesFcVerifiedCoordinates() { + stubAccessibleTask(); + Answer current = answer(ANSWER_A, 9L, 0); + current.setVersion(11); + when(answerRepository.findByUuidAndSchId(ANSWER_A, SCHOOL)).thenReturn(Optional.of(current)); + when(snapshotService.currentAuthorized(any(), eq(SCHOOL), eq(ANSWER_A))) + .thenReturn(snapshotView(ANSWER_A, 9L, HASH_D, false)); + when(manifestRepository.existsByTaskIdAndAnswerUuid(1L, ANSWER_A)).thenReturn(true); + + TaskAnswerReply reply = service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_A, null); + + assertThat(reply.getPresence()).isEqualTo(FormSyncModels.PRESENCE_PRESENT); + SnapshotEnvelope envelope = reply.getEnvelope(); + assertThat(envelope.getFormUuid()).isEqualTo(FORM_UUID); + // 清单里钉的是 7 版,中心当前值是 11 版:修复信封必须用后者 + assertThat(envelope.getFormVersion()).isEqualTo(11); + assertThat(reply.getFormVersion()).isEqualTo(11); + assertThat(envelope.getConsumerAppKey()).isEqualTo(CONSUMER); + assertThat(envelope.getConsumerSchoolId()).isEqualTo(SCHOOL); + assertThat(envelope.getSourceAppKey()).isEqualTo("100021"); + assertThat(envelope.getGrantVersion()).isEqualTo(1); + assertThat(envelope.getAnswerRevision()).isEqualTo(9L); + assertThat(envelope.getContentHash()).isEqualTo(HASH_D); + assertThat(envelope.getEventId()).hasSize(32); + assertThat(envelope.getEventType()).isEqualTo("updated"); + assertThat(envelope.getProtocolVersion()).isEqualTo("fc.answer.snapshot.v2"); + } + + @Test + @DisplayName("修订号为 1 的当前值走 created,墓碑走 tombstoned(同一权威入口)") + void tombstoneReplyKeepsAuthoritativeShape() { + stubAccessibleTask(); + when(answerRepository.findByUuidAndSchId(ANSWER_A, SCHOOL)) + .thenReturn(Optional.of(answer(ANSWER_A, 5L, 1))); + when(snapshotService.currentAuthorized(any(), eq(SCHOOL), eq(ANSWER_A))) + .thenReturn(snapshotView(ANSWER_A, 1L, HASH_E, true)); + when(manifestRepository.existsByTaskIdAndAnswerUuid(1L, ANSWER_A)).thenReturn(true); + + TaskAnswerReply reply = service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_A, null); + + assertThat(reply.getEnvelope().getEventType()).isEqualTo("tombstoned"); + assertThat(reply.getEnvelope().getIsDeleted()).isTrue(); + + when(snapshotService.currentAuthorized(any(), eq(SCHOOL), eq(ANSWER_B))) + .thenReturn(snapshotView(ANSWER_B, 1L, HASH_F, false)); + when(answerRepository.findByUuidAndSchId(ANSWER_B, SCHOOL)) + .thenReturn(Optional.of(answer(ANSWER_B, 1L, 0))); + assertThat(service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_B, null).getEnvelope().getEventType()) + .isEqualTo("created"); + } + + @Test + @DisplayName("缺席证明要三条件都成立:清单完整 + 中心当前读不到 + 绑定本地 expectedRevision") + void absenceProofRequiresCompletedManifestAndExpectedRevision() { + stubAccessibleTask(); + when(answerRepository.findByUuidAndSchId(ANSWER_Z, SCHOOL)).thenReturn(Optional.empty()); + when(manifestRepository.existsByTaskIdAndAnswerUuid(1L, ANSWER_Z)).thenReturn(false); + + TaskAnswerReply withoutRevision = service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_Z, null); + assertThat(withoutRevision.getPresence()).isEqualTo(FormSyncModels.PRESENCE_UNKNOWN); + assertThat(withoutRevision.getReasonCode()) + .isEqualTo(AnswerReconcileService.ERR_EXPECTED_REVISION_REQUIRED); + assertThat(withoutRevision.getAbsenceProof()).isNull(); + + TaskAnswerReply reply = service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_Z, 6L); + assertThat(reply.getPresence()).isEqualTo(FormSyncModels.PRESENCE_ABSENT); + assertThat(reply.getAbsenceProof()).isNotNull(); + assertThat(reply.getAbsenceProof().getTaskId()).isEqualTo(TASK_ID); + assertThat(reply.getAbsenceProof().getFormUuid()).isEqualTo(FORM_UUID); + assertThat(reply.getAbsenceProof().getSchId()).isEqualTo(SCHOOL); + assertThat(reply.getAbsenceProof().getAnswerUuid()).isEqualTo(ANSWER_Z); + assertThat(reply.getAbsenceProof().getExpectedRevision()).isEqualTo(6L); + assertThat(reply.getAbsenceProof().getProofId()).hasSizeLessThanOrEqualTo(128); + } + + @Test + @DisplayName("期间新增(中心当前有、清单里没有)绝不签缺席证明") + void answerAddedAfterWindowIsNeverAbsent() { + stubAccessibleTask(); + when(answerRepository.findByUuidAndSchId(ANSWER_B, SCHOOL)) + .thenReturn(Optional.of(answer(ANSWER_B, 1L, 0))); + when(snapshotService.currentAuthorized(any(), eq(SCHOOL), eq(ANSWER_B))) + .thenReturn(snapshotView(ANSWER_B, 1L, HASH_F, false)); + when(manifestRepository.existsByTaskIdAndAnswerUuid(1L, ANSWER_B)).thenReturn(false); + + TaskAnswerReply reply = service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_B, 1L); + + assertThat(reply.getPresence()).isEqualTo(FormSyncModels.PRESENCE_PRESENT); + assertThat(reply.getAbsenceProof()).isNull(); + } + + @Test + @DisplayName("清单里有但当前读不到权威快照时只报告,不反向签缺席证明") + void manifestRowWithoutReadableSnapshotIsReportOnly() { + stubAccessibleTask(); + when(answerRepository.findByUuidAndSchId(ANSWER_A, SCHOOL)).thenReturn(Optional.empty()); + when(manifestRepository.existsByTaskIdAndAnswerUuid(1L, ANSWER_A)).thenReturn(true); + + TaskAnswerReply reply = service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_A, 3L); + + assertThat(reply.getPresence()).isEqualTo(FormSyncModels.PRESENCE_UNKNOWN); + assertThat(reply.getReasonCode()).isEqualTo(AnswerReconcileService.ERR_MANIFEST_INCOMPLETE); + assertThat(reply.getAbsenceProof()).isNull(); + } + + // ==================== 授权与身份 ==================== + + @Test + @DisplayName("撤权:读取时发现授权失效即取消任务,剩余投递与修复停止") + void revokedGrantCancelsTaskOnRead() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(taskRepository.markCancelled(eq(1L), any(LocalDateTime.class), anyString())) + .thenAnswer(call -> { + task.setState(AnswerReconcileTask.STATE_CANCELLED); + return 1; + }); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, null, 2)) + .isInstanceOf(BizException.class) + .hasMessageContaining(AnswerReconcileService.ERR_GRANT_REVOKED); + assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_CANCELLED); + } + + @Test + @DisplayName("授权纪元进位:旧轮结论作废,按取消处置而不是继续放行") + void grantEpochAdvanceCancelsTask() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING); + task.setGrantVersion(1); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(taskRepository.markCancelled(eq(1L), any(LocalDateTime.class), anyString())).thenReturn(1); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 4))); + + assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, null, 2)) + .isInstanceOf(BizException.class) + .hasMessageContaining("授权纪元已进位"); + verify(taskRepository).markCancelled(eq(1L), any(LocalDateTime.class), anyString()); + } + + @Test + @DisplayName("别的消费方拿已验签身份读别人任务:冒充即拒,不因建任务时有权永久放行") + void otherConsumerCannotReadForeignTask() { + stubAccessibleTask(); + + assertThatThrownBy(() -> service.manifestPage(caller(OTHER_CONSUMER), TASK_ID, null, 2)) + .isInstanceOf(BizException.class).hasMessageContaining("不符"); + verify(manifestRepository, never()).findPage(anyLong(), anyString(), any(Pageable.class)); + } + + @Test + @DisplayName("已取消任务不再答复清单与快照") + void cancelledTaskStopsServing() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_CANCELLED); + task.setCancelRequested(1); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> service.taskAnswer(caller(CONSUMER), TASK_ID, ANSWER_A, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining(AnswerReconcileService.ERR_CANCELLED); + } + + // ==================== 结果回报 ==================== + + @Test + @DisplayName("同 taskId+attempt 的重复回报按幂等吸收,不新增结果行、不推进状态") + void duplicateResultSameAttemptIsAbsorbed() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING); + task.setAttempt(2); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(resultRepository.findByTaskIdAndAttempt(1L, 2)) + .thenReturn(Optional.of(result(2, AnswerReconcileTask.STATE_COMPLETED))); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + + ResultAck ack = service.submitResult(caller(CONSUMER), TASK_ID, + report(2, AnswerReconcileTask.STATE_COMPLETED)); + + assertThat(ack.getAccepted()).isTrue(); + assertThat(ack.getDuplicate()).isTrue(); + verify(resultRepository, never()).save(any(AnswerReconcileResult.class)); + verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any()); + } + + @Test + @DisplayName("旧 attempt 的回报只留档,不推进任务状态(不倒退新 attempt)") + void staleAttemptDoesNotAdvanceState() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING); + task.setAttempt(3); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(resultRepository.findByTaskIdAndAttempt(1L, 1)).thenReturn(Optional.empty()); + when(resultRepository.maxAttempt(1L)).thenReturn(2); + when(resultRepository.save(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0)); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + + ResultAck ack = service.submitResult(caller(CONSUMER), TASK_ID, + report(1, AnswerReconcileTask.STATE_FAILED)); + + assertThat(ack.getStaleAttempt()).isTrue(); + assertThat(ack.getAccepted()).isTrue(); + verify(resultRepository).save(any(AnswerReconcileResult.class)); + verify(taskRepository, never()).advanceTerminal(anyLong(), anyString(), any(), any(), any()); + } + + @Test + @DisplayName("假 result:回报 attempt 超过中心已派发数即拒;跨消费方身份同样拒") + void resultFromUnknownAttemptOrCallerIsRejected() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_DISPATCHED); + task.setAttempt(1); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + + assertThatThrownBy(() -> service.submitResult(caller(CONSUMER), TASK_ID, + report(7, AnswerReconcileTask.STATE_COMPLETED))) + .isInstanceOf(BizException.class).hasMessageContaining("超过中心已派发的"); + + assertThatThrownBy(() -> service.submitResult(caller(OTHER_CONSUMER), TASK_ID, + report(1, AnswerReconcileTask.STATE_COMPLETED))) + .isInstanceOf(BizException.class).hasMessageContaining("不符"); + verify(resultRepository, never()).save(any(AnswerReconcileResult.class)); + } + + @Test + @DisplayName("两类成功状态区别:PARTIAL 推进终态,RUNNING 只续租;错误明细有上限") + void completedAndPartialAreDifferentStates() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_DISPATCHED); + task.setAttempt(1); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(resultRepository.findByTaskIdAndAttempt(eq(1L), anyInt())).thenReturn(Optional.empty()); + when(resultRepository.maxAttempt(1L)).thenReturn(0); + when(resultRepository.save(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0)); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + + service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_PARTIAL)); + verify(taskRepository).advanceTerminal(eq(1L), eq(AnswerReconcileTask.STATE_PARTIAL), + any(LocalDateTime.class), isNull(), isNull()); + + service.submitResult(caller(CONSUMER), TASK_ID, report(1, AnswerReconcileTask.STATE_RUNNING)); + verify(taskRepository).markRunning(eq(1L), any(LocalDateTime.class)); + verify(taskRepository, times(1)).advanceTerminal(anyLong(), anyString(), any(), any(), any()); + } + + @Test + @DisplayName("回报状态闭集外一律拒;QUEUED 是中心派发前的状态,对端不许自报") + void reportStateOutsideClosedSetIsRejected() { + stubAccessibleTask(); + assertThatThrownBy(() -> service.submitResult(caller(CONSUMER), TASK_ID, report(1, "DONE"))) + .isInstanceOf(BizException.class).hasMessageContaining("闭集"); + assertThatThrownBy(() -> service.submitResult(caller(CONSUMER), TASK_ID, + report(1, AnswerReconcileTask.STATE_QUEUED))) + .isInstanceOf(BizException.class).hasMessageContaining("QUEUED"); + } + + @Test + @DisplayName("错误明细逐字段规整:身份按任务改写、每条含 consumer/task/range/answer、条数与长度都有上限") + void errorDetailsAreBoundedAndSanitised() { + stubAccessibleTask(); + when(resultRepository.findByTaskIdAndAttempt(1L, 1)).thenReturn(Optional.empty()); + when(resultRepository.maxAttempt(1L)).thenReturn(0); + when(resultRepository.save(any(AnswerReconcileResult.class))).thenAnswer(call -> call.getArgument(0)); + properties.setMaxErrorItems(3); + List errors = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ReconcileError error = new ReconcileError(); + error.setCode("hash_conflict"); + error.setAnswerUuid(ANSWER_A); + error.setDetail(repeat("values 里有一份很长的内容", 80)); + error.setConsumerAppKey("冒充身份"); + errors.add(error); + } + ResultReport report = report(1, AnswerReconcileTask.STATE_PARTIAL); + report.setErrors(errors); + + ArgumentCaptor saved = ArgumentCaptor.forClass(AnswerReconcileResult.class); + service.submitResult(caller(CONSUMER), TASK_ID, report); + verify(resultRepository).save(saved.capture()); + + AnswerReconcileResult row = saved.getValue(); + assertThat(row.getErrorsJson()).doesNotContain("冒充身份"); + assertThat(row.getConsumerAppKey()).isEqualTo(CONSUMER); + assertThat(row.getErrorsJson()).contains("\"taskId\":\"" + TASK_ID + "\""); + assertThat(row.getErrorsJson()).contains("\"consumerSchoolId\":" + SCHOOL); + assertThat(row.getErrorsJson()).contains("\"formUuid\":\"" + FORM_UUID + "\""); + assertThat(row.getErrorsJson().split("\"code\"").length - 1).isEqualTo(3); + assertThat(row.getErrorsJson().length()).isLessThanOrEqualTo(properties.getMaxErrorChars()); + assertThat(row.getErrorCount()).isEqualTo(10); + } + + // ==================== 派发 ==================== + + @Test + @DisplayName("派发 2xx 只标 DISPATCHED:不写 COMPLETED,也不替对端作证") + void dispatchOnlyMarksDispatched() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + task.setAttempt(0); + when(taskRepository.findDispatchable(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(dispatchClient.enabled()).thenReturn(true); + when(taskRepository.countInFlightForConsumer(CONSUMER, SCHOOL, 1L)).thenReturn(0L); + when(taskRepository.claimForDispatch(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenAnswer(call -> { + task.setAttempt(1); + task.setState(AnswerReconcileTask.STATE_DISPATCHED); + return 1; + }); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + when(dispatchClient.dispatch(any(ReconcileInstruction.class))) + .thenReturn(DispatchOutcome.of(true, null, "已落库", 1, + AnswerReconcileTask.STATE_QUEUED, false)); + + assertThat(service.dispatchNextQuietly()).isEqualTo(1); + + ArgumentCaptor sent = ArgumentCaptor.forClass(ReconcileInstruction.class); + verify(dispatchClient).dispatch(sent.capture()); + assertThat(sent.getValue().getTaskId()).isEqualTo(TASK_ID); + assertThat(sent.getValue().getAttempt()).isEqualTo(1); + assertThat(sent.getValue().getPageSize()).isEqualTo(2); + assertThat(sent.getValue().getTotalPages()).isEqualTo(1); + assertThat(sent.getValue().getFormUuid()).isEqualTo(FORM_UUID); + assertThat(sent.getValue().getWindowStart()).isNotBlank(); + assertThat(task.getState()).isEqualTo(AnswerReconcileTask.STATE_DISPATCHED); + verify(taskRepository, never()).advanceTerminal(anyLong(), eq(AnswerReconcileTask.STATE_COMPLETED), + any(), any(), any()); + } + + @Test + @DisplayName("同一消费方已有在途任务时不派下一条(单并发)") + void dispatchWaitsForInFlightWorkOfSameConsumer() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + when(taskRepository.findDispatchable(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(dispatchClient.enabled()).thenReturn(true); + when(taskRepository.countInFlightForConsumer(CONSUMER, SCHOOL, 1L)).thenReturn(1L); + + assertThat(service.dispatchNextQuietly()).isZero(); + verify(taskRepository, never()).claimForDispatch(anyLong(), any(), any()); + } + + @Test + @DisplayName("后台地址未登记:不猜 URL、不改数据,任务退回 QUEUED 并写原因") + void unregisteredTargetKeepsTaskQueued() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + when(taskRepository.findDispatchable(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(dispatchClient.enabled()).thenReturn(true); + when(taskRepository.countInFlightForConsumer(CONSUMER, SCHOOL, 1L)).thenReturn(0L); + when(taskRepository.claimForDispatch(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(1); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + when(dispatchClient.dispatch(any(ReconcileInstruction.class))).thenReturn( + DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, "未登记", 1, null, false)); + + assertThat(service.dispatchNextQuietly()).isZero(); + verify(taskRepository).rescheduleDispatch(eq(1L), any(LocalDateTime.class), + eq(AnswerReconcileService.ERR_TARGET_NOT_REGISTERED), any()); + } + + @Test + @DisplayName("派发次数到上限即 FAILED,不再无限重投") + void dispatchGivesUpAfterMaxAttempts() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + task.setAttempt(1); + when(taskRepository.findDispatchable(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(dispatchClient.enabled()).thenReturn(true); + when(taskRepository.countInFlightForConsumer(CONSUMER, SCHOOL, 1L)).thenReturn(0L); + when(taskRepository.claimForDispatch(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenAnswer(call -> { + task.setAttempt(3); + return 1; + }); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + when(dispatchClient.dispatch(any(ReconcileInstruction.class))).thenReturn( + DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, "对端不可达", 3, null, false)); + + service.dispatchNextQuietly(); + + verify(taskRepository).advanceTerminal(eq(1L), eq(AnswerReconcileTask.STATE_FAILED), + any(LocalDateTime.class), eq(AnswerReconcileService.ERR_DISPATCH_FAILED), any()); + verify(taskRepository, never()).rescheduleDispatch(anyLong(), any(), anyString(), any()); + } + + @Test + @DisplayName("租约超期:还有预算按 attempt+1 重派,耗尽才 FAILED") + void expiredLeaseIsRequeuedThenFailed() { + AnswerReconcileTask first = task(AnswerReconcileTask.STATE_DISPATCHED); + first.setAttempt(1); + AnswerReconcileTask last = task(AnswerReconcileTask.STATE_RUNNING); + last.setAttempt(3); + when(taskRepository.findExpiredLease(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(first)); + + assertThat(service.recoverExpiredLeasesQuietly()).isEqualTo(1); + verify(taskRepository).requeueExpiredLease(eq(1L), any(LocalDateTime.class), + any(LocalDateTime.class), eq(AnswerReconcileService.ERR_LEASE_EXPIRED), any()); + + when(taskRepository.findExpiredLease(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(Collections.singletonList(last)); + assertThat(service.recoverExpiredLeasesQuietly()).isEqualTo(1); + verify(taskRepository).advanceTerminal(eq(1L), eq(AnswerReconcileTask.STATE_FAILED), + any(LocalDateTime.class), eq(AnswerReconcileService.ERR_LEASE_EXPIRED), any()); + } + + // ==================== 管理面 ==================== + + @Test + @DisplayName("单答卷重放:先重新过 ACL 再调 TASK_2 replayAuthorized") + void replayRechecksAclBeforeCallingDelivery() { + when(callerResolver.isRegisteredAppKey(CONSUMER)).thenReturn(true); + when(answerRepository.findByUuidAndSchId(ANSWER_A, SCHOOL)) + .thenReturn(Optional.of(answer(ANSWER_A, 3L, 0))); + DeliveryStateView state = new DeliveryStateView(); + state.setState(AnswerReconcileTask.STATE_QUEUED); + when(deliveryService.replayAuthorized(any(), anyString(), anyLong(), anyString())).thenReturn(state); + + DeliveryStateView view = service.replayAnswer(admin(), 0L, CONSUMER, SCHOOL, ANSWER_A, 3L, "op-1"); + + assertThat(view.getState()).isEqualTo(AnswerReconcileTask.STATE_QUEUED); + verify(accessService).requireRead(any(ConsumerPrincipal.class), eq(SCHOOL), eq(FORM_UUID)); + verify(deliveryService).replayAuthorized(any(), eq(ANSWER_A), eq(3L), eq("op-1")); + } + + @Test + @DisplayName("重放不接受未注册身份、不替不存在的答卷作证,校级也不能跨域") + void replayRejectsUnregisteredAndCrossSchool() { + when(callerResolver.isRegisteredAppKey(OTHER_CONSUMER)).thenReturn(false); + assertThatThrownBy(() -> service.replayAnswer(admin(), 0L, OTHER_CONSUMER, SCHOOL, ANSWER_A, 1L, "op")) + .isInstanceOf(BizException.class).hasMessageContaining("不是平台已注册应用"); + + when(callerResolver.isRegisteredAppKey(CONSUMER)).thenReturn(true); + when(answerRepository.findByUuidAndSchId(ANSWER_Z, SCHOOL)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.replayAnswer(admin(), 0L, CONSUMER, SCHOOL, ANSWER_Z, 1L, "op")) + .isInstanceOf(BizException.class).hasMessageContaining("不存在"); + + assertThatThrownBy(() -> service.replayAnswer(admin(), SCHOOL, CONSUMER, 7L, ANSWER_A, 1L, "op")) + .isInstanceOf(BizException.class).hasMessageContaining("本校域"); + verify(deliveryService, never()).replayAuthorized(any(), anyString(), anyLong(), anyString()); + } + + @Test + @DisplayName("管理面取消把非终态任务落成 CANCELLED;跨域寻址按不存在回答") + void cancelStopsRemainingReads() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_RUNNING); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + when(taskRepository.markCancelled(eq(1L), any(LocalDateTime.class), anyString())).thenAnswer(call -> { + task.setState(AnswerReconcileTask.STATE_CANCELLED); + task.setCancelRequested(1); + return 1; + }); + + TaskView view = service.cancel(0L, TASK_ID, admin()); + + assertThat(view.getState()).isEqualTo(AnswerReconcileTask.STATE_CANCELLED); + assertThatThrownBy(() -> service.cancel(SCHOOL + 1, TASK_ID, admin())) + .isInstanceOf(BizException.class).hasMessageContaining(AnswerReconcileService.ERR_TASK_NOT_FOUND); + assertThatThrownBy(() -> service.manifestPage(caller(CONSUMER), TASK_ID, null, 2)) + .isInstanceOf(BizException.class).hasMessageContaining(AnswerReconcileService.ERR_CANCELLED); + } + + @Test + @DisplayName("清单摘要读取走本任务新增只读仓库,不借 TASK_2 的增量拉取门面") + void manifestReadsUseOwnReadOnlyRepository() { + AnswerReconcileTask task = freshQueuedTask(); + when(taskRepository.findPendingMaterialization(any(Pageable.class))) + .thenReturn(Collections.singletonList(task)); + when(taskRepository.claimMaterialization(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(1); + when(taskRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(task)); + when(manifestRepository.maxSeqNo(1L)).thenReturn(-1); + when(readRepository.findScopeAfterUuid(eq(FORM_UUID), eq(SCHOOL), anyString(), any(Pageable.class))) + .thenReturn(Collections.singletonList(answer(ANSWER_A, 2L, 0))); + when(snapshotService.contentHash(any(Answer.class))).thenReturn(HASH_A); + + service.materializeNextQuietly(); + + verify(readRepository, atLeastOnce()).findScopeAfterUuid(eq(FORM_UUID), eq(SCHOOL), anyString(), + any(Pageable.class)); + verify(answerRepository, never()).findPullAfterId(anyString(), anyLong(), any(), any()); + verify(answerRepository, never()).findPullUpdatedSince(anyString(), anyLong(), any(), any(), any()); + } + + // ==================== 夹具 ==================== + + private void stubHappyCoord() { + when(callerResolver.isRegisteredAppKey(CONSUMER)).thenReturn(true); + when(taskRepository.findByIdempotencyKey(anyString())).thenReturn(Optional.empty()); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + when(formRepository.findByUuid(FORM_UUID)).thenReturn(Optional.of(form(SCHOOL))); + when(readRepository.countScope(FORM_UUID, SCHOOL)).thenReturn(2L); + } + + private void stubAccessibleTask() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + when(taskRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(task)); + when(taskRepository.save(any(AnswerReconcileTask.class))).thenAnswer(call -> call.getArgument(0)); + when(grantRepository.findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(FORM_UUID, SCHOOL, CONSUMER)) + .thenReturn(Optional.of(grant(1L, FORM_UUID, 1))); + } + + /** 未物化的新任务(清单空、结束标记 0),物化用例的起点 */ + private AnswerReconcileTask freshQueuedTask() { + AnswerReconcileTask task = task(AnswerReconcileTask.STATE_QUEUED); + task.setManifestState(AnswerReconcileTask.MANIFEST_PENDING); + task.setManifestComplete(0); + task.setTotalRows(0); + task.setTotalPages(0); + task.setMaterializedRows(0); + task.setMaterializedPages(0); + task.setCheckpointCursor(null); + return task; + } + + private static TaskCreateRequest formRequest(String idempotencyKey) { + TaskCreateRequest request = new TaskCreateRequest(); + request.setScope("FORM"); + request.setConsumerAppKey(CONSUMER); + request.setConsumerSchoolId(SCHOOL); + request.setFormUuid(FORM_UUID); + request.setIdempotencyKey(idempotencyKey); + return request; + } + + private static BoaUser admin() { + BoaUser user = mock(BoaUser.class); + when(user.getAccount()).thenReturn("yunhai-admin"); + return user; + } + + private static ConsumerPrincipal caller(String appKey) { + return new ConsumerPrincipal(appKey); + } + + private static AnswerReconcileTask task(String state) { + AnswerReconcileTask task = new AnswerReconcileTask(); + task.setId(1L); + task.setTaskUuid(TASK_ID); + task.setConsumerAppKey(CONSUMER); + task.setConsumerSchoolId(SCHOOL); + task.setFormUuid(FORM_UUID); + task.setFormVersion(7); + task.setTriggerType(AnswerReconcileTask.TRIGGER_MANUAL); + task.setIdempotencyKey("M:op:" + CONSUMER + ":" + SCHOOL + ":" + FORM_UUID); + task.setState(state); + task.setManifestState(AnswerReconcileTask.MANIFEST_COMPLETE); + task.setManifestComplete(1); + task.setTotalRows(2); + task.setTotalPages(1); + task.setMaterializedRows(2); + task.setMaterializedPages(1); + task.setCheckpointCursor(ANSWER_B); + task.setWindowStart(LocalDateTime.of(2026, 10, 21, 2, 0)); + task.setWindowEnd(LocalDateTime.of(2026, 10, 21, 2, 1)); + task.setGrantVersion(1); + task.setAttempt(1); + task.setCancelRequested(0); + return task; + } + + private static FormConsumerGrant grant(Long id, String formUuid, int grantVersion) { + FormConsumerGrant grant = new FormConsumerGrant(); + grant.setId(id); + grant.setFormUuid(formUuid); + grant.setConsumerSchoolId(SCHOOL); + grant.setConsumerAppKey(CONSUMER); + grant.setReadAllowed(1); + grant.setSubscribeAllowed(1); + grant.setEnabled(1); + grant.setGrantVersion(grantVersion); + return grant; + } + + private static Form form(Long schId) { + Form form = new Form(); + form.setUuid(FORM_UUID); + form.setSchId(schId); + form.setCurrentVersion(7); + form.setIsDeleted(0); + return form; + } + + private static Answer answer(String uuid, Long revision, Integer isDeleted) { + Answer answer = new Answer(); + answer.setUuid(uuid); + answer.setFormUuid(FORM_UUID); + answer.setSchId(SCHOOL); + answer.setVersion(7); + answer.setAnswerRevision(revision); + answer.setIsDeleted(isDeleted); + answer.setStatus("SUBMITTED"); + answer.setValuesJson("{}"); + return answer; + } + + private static AnswerReconcileManifestRow manifestRow(String answerUuid, Long revision, String hash, + Integer isDeleted) { + AnswerReconcileManifestRow row = new AnswerReconcileManifestRow(); + row.setTaskId(1L); + row.setTaskUuid(TASK_ID); + row.setFormUuid(FORM_UUID); + row.setFormVersion(7); + row.setAnswerUuid(answerUuid); + row.setRevision(revision); + row.setContentHash(hash); + row.setIsDeleted(isDeleted); + row.setSeqNo(0); + return row; + } + + private SnapshotView snapshotView(String answerUuid, Long revision, String hash, boolean deleted) { + Snapshot snapshot = new Snapshot(); + snapshot.setSchId(SCHOOL); + snapshot.setAppCode("svc-hall"); + snapshot.setCode("FORM-A"); + snapshot.setStatus("SUBMITTED"); + snapshot.setValues(mapper.createObjectNode()); + SnapshotView view = new SnapshotView(); + view.setAnswerUuid(answerUuid); + view.setAnswerRevision(revision); + view.setContentHash(hash); + view.setIsDeleted(deleted); + view.setSnapshot(snapshot); + return view; + } + + private static AnswerReconcileResult result(int attempt, String state) { + AnswerReconcileResult row = new AnswerReconcileResult(); + row.setTaskId(1L); + row.setTaskUuid(TASK_ID); + row.setConsumerAppKey(CONSUMER); + row.setConsumerSchoolId(SCHOOL); + row.setFormUuid(FORM_UUID); + row.setAttempt(attempt); + row.setState(state); + return row; + } + + private static ResultReport report(int attempt, String state) { + ResultReport report = new ResultReport(); + report.setAttempt(attempt); + report.setState(state); + report.setLocalScanComplete(Boolean.TRUE); + report.setManifestComplete(Boolean.TRUE); + report.setScannedRows(2); + report.setPagesProcessed(1); + report.setMissingRepaired(0); + report.setStaleRepaired(1); + report.setHashConflicts(0); + report.setTombstonesRepaired(0); + report.setLocalAhead(0); + report.setDeferred(0); + report.setExtraReported(0); + report.setExtraRetired(0); + report.setErrors(new ArrayList()); + return report; + } + + /** 事务模板真跑回调:不真跑的话检查点与状态回写的断言全是空转 */ + private static TransactionTemplate runningTransactionTemplate() { + TransactionTemplate tx = mock(TransactionTemplate.class); + when(tx.execute(any())).thenAnswer(call -> + ((TransactionCallback) call.getArgument(0)).doInTransaction(null)); + doAnswer(call -> { + Consumer action = call.getArgument(0); + action.accept(null); + return null; + }).when(tx).executeWithoutResult(any()); + return tx; + } + + /** Java 8 没有 String.repeat:哈希夹具与长文案都用这两个助手 */ + private static String chars(char unit, int count) { + StringBuilder builder = new StringBuilder(count); + for (int i = 0; i < count; i++) { + builder.append(unit); + } + return builder.toString(); + } + + private static String repeat(String unit, int times) { + StringBuilder builder = new StringBuilder(unit.length() * times); + for (int i = 0; i < times; i++) { + builder.append(unit); + } + return builder.toString(); + } +}