t1~t3落地+10-22精修: 消费授权v3(owner/grant/订阅/目录)+答卷投递v2(可信提交上下文/快照/幂等关联)+答卷对账v1(清单/派发/租约/结果)
build / build-push (push) Successful in 1m26s
build / build-push (push) Successful in 1m26s
含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。 合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
# 答卷投递 v2 与可信建件(answer-delivery-v2)
|
||||
|
||||
任务书:`docs/tasks/TASK_2.md`(【任务 2】可信 FC 提交 + 完整答卷投影 + SH→FL 办件编排)。
|
||||
本文档是**契约与运维权威**:写清"v2 与 v1 差在哪、双方字段闭集、故障状态表、broker 隔离的实值来源、
|
||||
可信关联与恢复时序、撤权竞态的生效边界"。代码事实以本文为准,与代码分叉时**先改文档再改代码**。
|
||||
|
||||
依赖:TASK_1《consumer-access-v3.md》的 `FormConsumerAccessService`(owner/read/subscribe 与
|
||||
`grantVersion`)是唯一授权事实源;本契约不写第二套 ACL,只消费它。
|
||||
|
||||
---
|
||||
|
||||
## 1. 协议版本差额(先说清"新增了什么、旧的那条怎么算")
|
||||
|
||||
| 维度 | v1(既有,冻结) | v2(本任务新增) |
|
||||
|---|---|---|
|
||||
| exchange | `fc.events.v1`(topic,广播) | `fc.answer.delivery.v2`(topic,按消费方私有 routing key) |
|
||||
| 消息形状 | 十字段闭集、**不含值文档**(`FcEventPublisher.Envelope`) | `SnapshotEnvelope` 十五字段,**含完整 snapshot** |
|
||||
| 版本键 | `version` = 钉住的 schema 版本 | `formVersion`(schema 版本)+ **`answerRevision`**(对外快照单调修订号) |
|
||||
| 可靠性 | afterCommit 尽力发、失败仅 warn、"拉取即事实源" | **答卷同事务落 outbox**,afterCommit 只唤醒扫描;publisher confirm + mandatory return |
|
||||
| 消费方判据 | 广播 key 用资源 `app_code` | routing key 用**显式 consumerAppKey + 租户域**:`fc.snapshot.v2.<appKey>.<schoolId>` |
|
||||
| 授权 | 无(能连 broker 就能看到元数据) | 逐订阅授权 + 每条带 `grantVersion`,发布前复核 |
|
||||
| 目标端点 | 无(消费方靠拉取) | 新增 `POST /oapi/form/submission-context`、`GET /oapi/form/submission-context/{intentId}/receipt`、`POST /oapi/form/answer-delivery/receipt`、`POST /oapi/form/answer-delivery/grants/verify` |
|
||||
|
||||
**v1 一条都不改**:字段闭集、routing key 形态、失败静默的取舍全部保留(`FcEventPublisherTest`
|
||||
的十字段断言仍然成立)。差异登记见 §9 待修清单第 3 条。
|
||||
|
||||
## 2. 快照修订号 `answerRevision`
|
||||
|
||||
`answer` 新增 `answer_revision BIGINT NOT NULL DEFAULT 0`:创建=1,此后**每个会改变对外快照的路径**
|
||||
在同事务内 +1,并与不可变 outbox 行一起落库。覆盖点(一处不漏):
|
||||
|
||||
| 路径 | 位置 | 事件类型 |
|
||||
|---|---|---|
|
||||
| 提交(手工/代录/导入/hosted) | `AnswerService#doSubmit` | `created` |
|
||||
| 本人编辑值 | `AnswerReadService#editBySubmitter` | `updated` |
|
||||
| 管理员编辑值 | `AnswerAdminService#doEdit` | `updated` |
|
||||
| **改业务状态(含批量)** | `AnswerAdminService#doChangeStatus` | `updated` |
|
||||
| 软删(本人 + 管理面 + 批量) | `softDeleteBySubmitter` / `doSoftDelete` | `tombstoned` |
|
||||
|
||||
并发控制是两道闸,缺一都可能让"同一 revision 对应两份内容":
|
||||
|
||||
1. **行级 CAS**:`AnswerRepository#compareAndSetRevision(id, expected, next)`(JPQL UPDATE 自带行锁;
|
||||
必须在改动实体字段**之前**调用,否则自动 flush 会先把新号写进去、CAS 自我拒绝);
|
||||
2. **outbox 唯一键** `uk_delivery_revision(answer_uuid, answer_revision, consumer_app_key,
|
||||
consumer_school_id)`:CAS 没挡住的极端形态在这里撞键,整事务回滚。
|
||||
撞键被翻译成 `BizException(CUSTOM)`,**故意不**透传 `DataIntegrityViolationException`——
|
||||
提交链路外层会把 DIVE 当"幂等重放"返回原答卷,那等于把并发丢更新伪装成重放成功。
|
||||
|
||||
没上 JPA `@Version`:那会让存量的分离实体 `save()` 路径(并发 IT、批量导入)突然开始抛
|
||||
`OptimisticLockException`,属于跨批次行为变更,不在本任务授权范围内。
|
||||
|
||||
## 3. 规范化与哈希:`fc.canon.v1`(跨端契约,两端各一份实现)
|
||||
|
||||
实现:form-center `AnswerCanonicalizer`、svc-hall `AnswerProjectionStore`(消费端复算)。
|
||||
规则闭集(要改语义就升算法号,不许原地改):
|
||||
|
||||
1. 对象键按 **UTF-16 代码元字典序**(`String.compareTo`)升序递归输出——钉比较器而不是"码点序",
|
||||
两端都是 JVM,同一个比较器才逐字一致;
|
||||
2. 数组顺序保留(多选/排序是业务语义);
|
||||
3. 数字 `decimalValue().stripTrailingZeros().toPlainString()`(`100.0→100`、`1E+2→100`,无指数);
|
||||
4. 字符串只转义引号、反斜杠与控制字符(`\b\f\n\r\t` 短转义,其余 C0 用反斜杠+u+00+两位大写十六进制),
|
||||
非 ASCII(含中文)原样输出,哈希取 UTF-8 字节;
|
||||
5. **空值策略:显式 null 成员保留**(缺失键与 `"k":null` 是两种事实,合并会丢"字段被清空"这次变化);
|
||||
6. 快照顶层 `intent` 成员**不参与哈希**(它只对单个 consumer 可见,留着就会出现"同 revision 两份 hash");
|
||||
7. 时间恒为 `yyyy-MM-dd'T'HH:mm:ss`(不用 `ISO_LOCAL_DATE_TIME`:整分时它会省掉秒段,跨端就比不了);
|
||||
8. 投递时间、重试计数、消费端扩展列都是信封/本地列,不进快照。
|
||||
|
||||
### 跨端夹具(逐字比对,两端测试钉同一串)
|
||||
|
||||
输入快照(十个成员):`schId=5, appCode="demo", code="honor_apply", submitterId=1001, subjectId=7,
|
||||
status="processed", values={"a":{"z":1,"y":null},"b":[2,1],"n":100.0,"e":true},
|
||||
answerNo=null, createTime="2026-10-21T10:00:00", updateTime=null`
|
||||
|
||||
规范串(`FcAnswerDeliveryTest.CANONICAL_VECTOR` = `ShAnswerFlowTest.CANONICAL_VECTOR`):
|
||||
|
||||
```json
|
||||
{"answerNo":null,"appCode":"demo","code":"honor_apply","createTime":"2026-10-21T10:00:00","schId":5,"status":"processed","subjectId":7,"submitterId":1001,"updateTime":null,"values":{"a":{"y":null,"z":1},"b":[2,1],"e":true,"n":100}}
|
||||
```
|
||||
|
||||
`contentHash` = 上述字节串的 SHA-256 小写十六进制(64 位)。
|
||||
消费端复算不一致 ⇒ 毒载荷拒绝;与**已存投影行**不一致 ⇒ 冲突待核验不覆盖(§5)。
|
||||
|
||||
### 信封示例(闭集十五字段;`intent` 只在 `snapshot` 内、只对目标 consumer 出现)
|
||||
|
||||
```json
|
||||
{"protocolVersion":"fc.answer.snapshot.v2","eventId":"5f0a…","eventType":"updated",
|
||||
"sourceAppKey":"100021","consumerAppKey":"100018","consumerSchoolId":5,"grantVersion":7,
|
||||
"formUuid":"f0e1…","formVersion":4,"answerUuid":"a1b2…","answerRevision":3,"isDeleted":false,
|
||||
"contentHash":"<64hex>","occurredAt":"2026-10-21T10:05:00+08:00",
|
||||
"snapshot":{"schId":5,"appCode":"demo","code":"honor_apply","submitterId":1001,"subjectId":1001,
|
||||
"status":"processed","values":{},"answerNo":null,"createTime":"…","updateTime":"…",
|
||||
"intent":{"intentId":"svchall-0123…","consumerAppKey":"100018","consumerSchoolId":5,
|
||||
"submitterId":1001,"formVersion":4,"linkedRevision":1}}}
|
||||
```
|
||||
|
||||
不进快照的:`anon_ip`/`anon_ua`(审计数据,契约 v2 C8/C12)、库内 `id`(契约 §1)、附件字节
|
||||
(值里只有 `{id,fileName,key}`,P34)。**无订阅的简单表单不产生任何投递行**,答卷照常提交。
|
||||
|
||||
## 4. 可信提交上下文(A.1~A.4)
|
||||
|
||||
```
|
||||
SH POST /api/apply/intents(body 只有 siteCode/itemCode/clientKey)
|
||||
└ 会话取人与校 → 事项入口判定:已发布 ∧ applyUrl 为空 ∧ onlineFlag=1
|
||||
∧ TASK_1 成对绑定(fcFormUuid+fcFormVersion) ∧ flowGuid 就绪 ← 任一不过:不建件、不调流程
|
||||
└ 落 fc_apply_intent(uk: 校/人/事项/clientKey)
|
||||
└ 签名 POST /oapi/form/submission-context(intentId=svchall-<自然键哈希>)
|
||||
FC:resolveSignedCaller → requireRead + requireSubscribe
|
||||
→ 按 (formUuid,formVersion) 精确取版本行 + 表单当前开放填报
|
||||
→ embedOrigin / callbackUrl 取服务端配置(缺键即拒,浏览器 URL 一律不采信)
|
||||
→ 同 (appKey,intentId) 回到同一行;令牌在该行上轮换(旧令牌即失效)
|
||||
→ 存 SHA-256 摘要 + 过期 + 绑定坐标(默认 30 分钟)
|
||||
└ 返回 fillPageUrl = {FC 前端基址}/f/{code}?mode=embed&appCode=…&ctx=<token>
|
||||
|
||||
被嵌页(form-center front Fill.vue,hosted 通道)
|
||||
└ GET /api/form/hosted/schema?contextToken → 按令牌钉的那一版取 schema(绝不回落当前发布版)
|
||||
└ POST /api/form/hosted/submit(body 同 /api/form/submit + contextToken)
|
||||
FC 在答卷事务内:redeem(存在→未过期→会话用户=断言填报人→会话校=上下文租户域,
|
||||
错人/错校/过期/不存在一律拒)→ 坐标逐字核对 → 落答卷(answer_revision=1)
|
||||
→ 原子关联 submission_context.answer_uuid(uk 双向唯一)→ 同事务写 outbox
|
||||
→ 同 token 同幂等键=原答卷重放;换幂等键重用=拒绝
|
||||
└ 成功只 postMessage {proto:"fc",v:1,type:"success",hosted:true} 给**服务端锁定的精确 origin**
|
||||
(不带 answerId、不带版本号;referrer 回落与同 hostname 降级都不可用)
|
||||
|
||||
SH POST /api/apply/intents/{id}/confirm("可确认"信号只触发这一步)
|
||||
└ 签名 GET /oapi/form/submission-context/{intentId}/receipt
|
||||
FC:只向所属消费应用返回 + 读取时再核一次 requireRead
|
||||
→ PENDING(还没答卷,不建件) / SUBMITTED(坐标+提交者+revision+hash+快照)
|
||||
└ 逐字复核 consumerAppKey / 学校 / 提交者 / formUuid+formVersion → 建件(一次)
|
||||
```
|
||||
|
||||
**`bizId`/`answerId` 在浏览器可提交的形状里根本没有字段位**——"不能用浏览器传入 bizId 直接建件"
|
||||
是结构事实,不是审查约定。匿名与普通提交仍是中心自用的两条既有通道;hosted 首批只允许登录本人,
|
||||
匿名答卷的 `submitter_id=0` 与上下文断言的真人对不上,"匿名完成消息用于本人办件"这条路径
|
||||
在 redeem 处就被结构性挡住。
|
||||
|
||||
## 5. 投影、inbox 与顺序无关合并(A.5/B.5)
|
||||
|
||||
三张表各管一件事,**没有第五张"双轨表"**:
|
||||
|
||||
| 表 | 唯一键 | 管什么 |
|
||||
|---|---|---|
|
||||
| `fc_apply_intent` | `(sch,submitter,item,clientKey)`、`(consumerAppKey,fcIntentId)`、`answer_uuid`、`apply_id` | 业务意图;"一次意图一个办件"的库级形态 |
|
||||
| `fc_answer_projection` | `(answer_uuid, sch_id)`、`intent_id` | 答卷在本校的唯一投影 + **意图↔答卷↔办件的独立关联表**(保存 formUuid/formVersion/answerRevision/flowGuid) |
|
||||
| `fc_answer_inbox` | `(event_id, consumer_app_key)` | 每封事件怎么处置的台账(APPLIED/IGNORED/CONFLICT/DEFERRED/REJECTED)+ 回执上报状态 |
|
||||
|
||||
规则(`AnswerProjectionStore#applySnapshot`,一个事务内完成,DB 提交成功才 manual ack):
|
||||
|
||||
- 更高 revision 才应用;更低忽略;同 revision 同 hash 幂等;同 revision 异 hash ⇒ `conflict_flag=1`
|
||||
留证**不覆盖**(投影与台账同时记,等人工/对账核验);
|
||||
- 墓碑只是"更高 revision 的一种取值",本地保留 revision;旧 `created` 复活不了它;
|
||||
- `biz_ext_json` 是业务端扩展列,中心快照永不写;
|
||||
- 每条事件**不回查答案值文档**(全文就在消息里);应用结果经 §4 的成对签名端点回执,
|
||||
FC 按 `eventId + 消费方 + revision` 逐字复核并幂等记录;
|
||||
- **没有已验证意图就不建件**:事件先到 ⇒ 只落 `UNLINKED` 投影;确认先到 ⇒ 意图行落
|
||||
`ANSWERED/APPLIED`;后到的一方撞进同一组唯一键,两条路径收敛到同一个 intent/answer,
|
||||
不会各建一个办件。
|
||||
|
||||
## 6. broker 隔离配置(实值来源,缺一条就别开 `RABBITMQ_HOST`)
|
||||
|
||||
v2 快照带完整值文档,"能连上 broker 的任何人可见"是不可接受的。以下每一项都是**部署契约的
|
||||
待填实值**,不是建议:
|
||||
|
||||
| 项 | 实值来源 | 必须为真的判据 |
|
||||
|---|---|---|
|
||||
| vhost | projman 注入 `RABBITMQ_VHOST`(form-center 与 svc-hall 同一 vhost,与其他租户隔离) | 该 vhost 内不存在任何非授权消费者可绑的队列;跨应用共享 vhost 上**不得**发明文快照 |
|
||||
| 发布方凭据 | `RABBITMQ_USER/PASS`(form-center 侧) | 账号权限:仅对 `fc.answer.delivery.v2` 有 `write`,对 `configure` 只覆盖自身 exchange 声明 |
|
||||
| 消费方凭据 | svc-hall 侧同名 env | 只能 `configure`/`write`/`read` 自己前缀的队列与 binding:`svc-hall.fc.answer.snapshot.v2*`、`fc.snapshot.v2.<自身 appKey>.*`;**禁止** `#` 或他人前缀的通配绑定 |
|
||||
| 队列与死信 | `FcAnswerMqConfiguration` 声明 | `svc-hall.fc.answer.snapshot.v2`(durable)+ `.dlq`(有界重试 3 次后不 requeue 落此,可查) |
|
||||
| confirm/return | form-center `application.yml`:`publisher-confirm-type=correlated`、`publisher-returns=true`、`template.mandatory=true` | 拿不到逐条 confirm 就**不发送**(宁可积压 PENDING,也不按未确认投递) |
|
||||
| 隔离能力确认 | 运维在交付单上逐项签字 | 上述任一项不成立时,v2 全量快照**不得**启用;此时只保留"签名收据"这条建件路径(本机联调即该形态) |
|
||||
|
||||
## 7. 故障状态表(两侧 + 恢复;按码处置,不靠文案)
|
||||
|
||||
### 7.1 form-center 投递行 `answer_delivery.state`
|
||||
|
||||
| 状态 | 含义 | 进入条件 | 离开 |
|
||||
|---|---|---|---|
|
||||
| `PENDING` | 已落库待发 | 答卷事务内入队;或未配 broker 时长期停留 | 扫描取出→发送 |
|
||||
| `BROKER_ACCEPTED` | **总线收下** | confirm ack=true | 终态(业务侧结论看 `consumer_applied_at`) |
|
||||
| `FAILED` | 发送异常/不可路由/broker nack | AmqpException、mandatory return、ack=false | 退避 `20s×2^n`(base/cap 可配)后重扫 |
|
||||
| `DEAD` | 有界重试耗尽 | retry_count ≥ max-retries(8) | 人工/`failedPage` 处置 |
|
||||
| `GRANT_REVOKED` | 发布前 grant 复核未过 | 订阅撤销或 `grantVersion` 进位 | 终态,**不重放旧 grant** |
|
||||
|
||||
**两个时刻分列**:`broker_accepted_at`(只由 confirm 回调写)与 `consumer_applied_at` +
|
||||
`consumer_outcome`(只由消费方签名回执写)。broker 的 ack 永远不构成业务成功证据;
|
||||
回执自称 `APPLIED` 但 hash 与信封不符 ⇒ 记 `CONFLICT`;同 eventId 重复上报不同结论 ⇒ 保留首次。
|
||||
|
||||
### 7.2 svc-hall inbox `state` / `report_state`
|
||||
|
||||
`APPLIED` / `IGNORED`(低 revision 或同版同 hash 重放)/ `CONFLICT`(同版异 hash,不覆盖)/
|
||||
`DEFERRED`(授权无法确认,暂缓写入)/ `REJECTED`(闭集或哈希不过,毒载荷 ack 后留档);
|
||||
`report_state`=`PENDING/SENT/FAILED` 由重报扫描追平(本地已落库但中心没收到确认时,
|
||||
只补确认,不回滚事实)。
|
||||
|
||||
### 7.3 svc-hall `flow_apply_command.state`
|
||||
|
||||
| 状态 | 进入 | 处置 |
|
||||
|---|---|---|
|
||||
| `PENDING` | 与办件同短事务落库 | 事务外发送(HTTP 不占长事务) |
|
||||
| `UNKNOWN` | **任何**发送异常(超时/断连/4xx/5xx 一律算未知) | 先 `GET /oapi/flow/instance` 按业务键查 |
|
||||
| `DONE` | 发起成功,或查得远端 10/20/30/40 并回填真实状态 | 终态 |
|
||||
| `FAILED` | 退避中(含"流程中心未接入",等接入后自愈) | `next_retry_at` 到点重扫 |
|
||||
| `DEAD` | 重试预算耗尽(默认 6 次) | 人工处置;不无限重投 |
|
||||
|
||||
关键取舍:**不靠异常类型猜"远端有没有受理"**。查到 30/40 只回填镜像状态、**绝不重发 apply**
|
||||
(重发会命中中心 R4 重启,把已拒绝/已撤回的件重新跑起来);只有查无才用同一 `requestToken` 补发。
|
||||
|
||||
### 7.4 flow-center 发起守卫分支(`FlowApplyRetryModels.ApplyResultV2.branch`,稳定码)
|
||||
|
||||
| 码 | 判据 | 是否重启流程 |
|
||||
|---|---|---|
|
||||
| `CREATED` | 无既有实例 | 新建 + START 推进 |
|
||||
| `IDEMPOTENT` | 命中 10/20 | 否(既有语义逐字不变) |
|
||||
| `SAME_REQUEST` | `requestToken` 与行上相同 | **否**——同一次命令的重投 |
|
||||
| `STALE_ROUND` | 请求 `attempt` ≤ 行 `apply_round`(NULL 按 1) | 否 |
|
||||
| `REPLAY_ONLY` | `retryOnly=true` 命中既有实例(含 30/40) | 否 |
|
||||
| `RESTARTED` | 更高 `attempt` + 新 token 命中 30/40 | 是,走既有 R4 |
|
||||
| `CONCURRENT_CREATED` | 唯一键撞键后事务边界外重读命中 | 否(且先核归属) |
|
||||
|
||||
归属复核三条:行 `apply_app_key`、`submitter_id`、流程线 `flowGuid` 与请求**任一明确不符即拒**,
|
||||
不把别的应用相同 `bizType+bizId` 的办件当自己的结果。存量行(token/round 为 NULL)的守卫**缺省是关**。
|
||||
|
||||
### 7.5 时序
|
||||
|
||||
```
|
||||
提交点(答卷事务)→ revision+1 → outbox 行 → [提交] → afterCommit 唤醒扫描 → 发送(persistent)
|
||||
├ broker ack → broker_accepted_at (≠ 业务成功)
|
||||
├ broker nack → FAILED + 退避
|
||||
└ 不可路由/异常 → FAILED + 退避 →(耗尽)DEAD
|
||||
消费方:manual ack 只在 DB 提交后;签名回执 → consumer_applied_at / consumer_outcome
|
||||
进程崩溃窗口:outbox / flow_apply_command 都是库内行 → 重启后扫描续跑(不依赖内存闭包)
|
||||
```
|
||||
|
||||
## 8. TASK_3 必须一次交付的扩展点(签名即契约,后续只调用不编辑)
|
||||
|
||||
form-center:`AnswerSnapshotService.currentAuthorized(ConsumerPrincipal, Long consumerSchoolId, String answerUuid)`、
|
||||
`AnswerDeliveryService.replayAuthorized(ConsumerPrincipal, String answerUuid, Long expectedRevision, String operationId)`
|
||||
(稳定码 `QUEUED/ALREADY_DELIVERED/GRANT_REVOKED/REVISION_MISMATCH/NO_DELIVERY_ROW`,
|
||||
`operationId` 幂等吸收)、`AnswerDeliveryService.failedPage(ConsumerPrincipal, Long, Integer, Integer)`。
|
||||
svc-hall:普通消费保留 `AnswerProjectionStore.applySnapshot(SnapshotEnvelope)`;对账修复与分页改用下方§8.1明确的
|
||||
`repairAuthoritative(...)`、`manifestPage(...)`(只出轻量摘要与本地创建时间,不拉值文档)、
|
||||
`retireAbsent(AuthoritativeAbsenceProof)`(只做退役标记,不物理删、不改 revision;
|
||||
`expectedRevision` 与本地不符时**拒绝退役**并留证)。
|
||||
快照适配与对账共用 §3 的 canonicalization/hash 契约,两端不得各写一套。
|
||||
|
||||
### 8.1 Task2 前置修正:对账与普通消费分离(2026-10-21)
|
||||
|
||||
- 普通 `applySnapshot` 继续按MQ规则处理,同revision异hash不覆盖,且可能关联意图并建件;**TASK_3不得使用它执行修复**。
|
||||
- 新 `RepairResult repairAuthoritative(SnapshotEnvelope envelope, Long expectedRevision, String expectedHash, Integer expectedIsDeleted, String proofId)` 是服务端内部入口。TASK_3须先经平台签名的FC任务端点读取当前快照并核验task/consumer/school/form/answer/version/grant;proofId仅为审计引用,不是签名或浏览器可提交的授权凭据。使用现有fc.canon.v1复算hash,并绕过MQ授权缓存重新核验订阅且grantVersion必须相等。
|
||||
- expectedRevision/hash/isDeleted是本地读取的完整三元组;全部null仅表示观察到缺失。已有行用单条数据库条件更新检查三元组与school/form/answer仍匹配,更新仅限快照列与修复留证,保留bizExt/intent/apply/flow;缺失行插入UNLINKED投影。修复不调用建件、不写inbox或消费回执、不改意图。唯一键竞争须回滚并重新读取。
|
||||
- SH投影新增本地 `projection_version` 乐观锁列(与FC revision无关);权威条件更新同时递增该列,已读取旧实体的MQ/收据写入将触发乐观锁冲突,不能把权威修复覆盖回旧值。冲突必须整笔回滚,在事务外重新读取后有界重试。已有TASK_2库先执行 `svc-hall/server/sql/add_fc_projection_version.sql`,新库直接使用更新后的 `fc_projection_and_apply_intent.sql`,两脚本不可重复套用;本次未执行数据库升级。
|
||||
- 权威修复信封eventId最多36字符,可用任务/答卷操作的UUID;proofId最多128字符。新鲜授权响应必须同时匹配form/consumer/school及相等grantVersion,不能用别校/别应用响应或未来纪元放行。
|
||||
- 稳定返回:`APPLIED`、`LOCAL_CHANGED`(期望已变化,重新读取后有界重试)、`LOCAL_AHEAD`(本地revision更高,向FC复核且不降级)、`DEFERRED`(新鲜授权核验不通过,不写入)。同revision异hash仅在此权威入口可修复,普通MQ规则不变。
|
||||
- `ManifestPage manifestPage(Long schId, String formUuid, String cursor, Integer size)` 使用answerUuid升序keyset,初始cursor=null,按返回nextCursor继续;size默认200/上限500,多读一条确定completed。返回 `{rows,nextCursor,completed}`;rows为answerUuid/revision/contentHash/isDeleted/schId/createTime,结束时completed=true且nextCursor=null,不暴露库内id。
|
||||
- 本地分页不提供一致性快照证明。TASK_3须保存扫描结果/检查点,并用完整FC物化manifest、新鲜缺席证明及任务起点校验退役候选;本地createTime为空或晚于任务起点时不可退役。分页中途新增/变动不得被“没看到”解释为中心不存在。
|
||||
- 新增规则门禁 `ShReconcileExtensionTest`;MySQL实际条件更新/唯一键并发、签名任务端点及Rabbit运行期行为仍需联调验证,规则单测不替代它们。
|
||||
|
||||
## 9. 待修清单(本轮刻意不修;跨校那条必须在本任务联调验收前解决)
|
||||
|
||||
1. **跨校授权缺"调用方属于该租户域"的事实源(TASK_1 遗留,优先级最高)**。
|
||||
`FormConsumerAccessService` 判的是"(formUuid, consumerSchoolId, consumerAppKey) 三元键上有没有生效
|
||||
grant",而 `consumerSchoolId` 一直是**调用方申报**的:grant 创建侧只限制"校管理员只能授给本校",
|
||||
超管可代任意校授,于是"这个 appKey 到底能不能在 school=6 下消费数据"没有第二重证明。
|
||||
本任务的可信建件把这条链放大成写侧风险——SH 申报的是事项所在校,中心照单核。
|
||||
修向(择一,需授权):FC 侧建立 appKey↔可服务学校集合的事实源(UserBase 注册信息),
|
||||
或把 grant 创建限定为"消费方应用注册校"。**本轮按边界要求不改 TASK_1 的 ACL/目录文件**。
|
||||
2. **托管填报的资格口径两侧不同**:SH 入口判"事项发布 + 在线办理 + FC 绑定",FC 仍按表单
|
||||
`audience` 门判"这个会话身份能不能填这张表"。若中心侧表单人群配得比事项服务对象严
|
||||
(例如只含教师),学生会话会出现"能打开被嵌页、提交被 403"。修向:要么 SH 入口镜像 audience,
|
||||
要么 hosted 通道把资格判据换成"意图授权"。本轮按任务书口径**保留 audience 门不动**
|
||||
(它同时是 FC 自用通道的判据,改它要单独授权)。
|
||||
3. **v1 与 v2 的事件口径分叉**:改业务状态现在只产生 v2 的 `updated` 投递,
|
||||
v1 `fc.events.v1` 仍不发状态变更事件(v1 的 `type` 闭集与消费方约定都在冻结范围外)。
|
||||
需要显式决定"v1 冻结并声明"还是"v1 同步补发",否则仍依赖 v1 的旧消费方看不到 status 变化。
|
||||
4. **应急对账闸不认识新命令表**:`FlowReconcileJob` 仍只看 `service_apply.flow_status`,
|
||||
不知道 `flow_apply_command` 的 `UNKNOWN` 行(该文件构造器被基线测试逐位钉住,本轮不动)。
|
||||
大面积事故时开闸对账与恢复扫描可能同时推进同一条链路,需要后续统一到一个处置出口。
|
||||
5. **回调"更高版本 ⇒ 新一轮"的判定还没与 attempt 映射对齐**(任务书 C.4 后半句,本轮未做):
|
||||
`SvcFlowCallbackHandler#advance` 仍按 `instanceVersion > last_instance_version` 认定新轮,
|
||||
而本轮新增的持久轮次在 `flow_apply_command.attempt` 上,两侧没有交叉核对。
|
||||
当前实际影响有限(R4 重启只由用户显式重提触发,且 `markResubmitted` 会清空旧轮结果),
|
||||
但"版本更高"在严格口径下**不自动证明**是新一轮——需要回调侧按 intent/attempt 映射复核后再放行。
|
||||
本轮按"不顺手改既有接收器"处理,留待与 §9#4 一并在统一处置出口里做。
|
||||
|
||||
## 10. 未实现范围与证据边界(照实说)
|
||||
|
||||
- **问卷类独立后端未定位**:本文 §3~§7 是可直接执行的消费协议与适配要求(信封、canonicalization、
|
||||
inbox/投影唯一键、回执、撤权竞态)。**未宣称"三端之外所有消费者已接入"**,也不虚构该消费端源码。
|
||||
- **部门实施冻结**:新办件首次发起只做既有规则的成员就绪检查;部门来源切换、未来节点
|
||||
冻结/改派/挂起、当前科室停用行为细则**不在本任务**(按已知限制记录,不声称实现 D10 全部细则)。
|
||||
- 前端只做到**构建级验证**(`vite build` 两条绿),本机浏览器通道不可用 ⇒ 没有 iframe 视觉证据;
|
||||
"消息来源三重校验(精确 origin + `event.source` + proto/v)"目前只有代码与协议级依据。
|
||||
- 测试是**规则单测**(FC 21 / SH 26 / FL 12 条,全绿)。桩事务能证"答卷写与 outbox 写在同一事务回调内、
|
||||
回调抛出后两者都不落地、afterCommit 只唤醒扫描",**证不了 MySQL 的原子性**;
|
||||
manual ack、死信路由、publisher confirm 的 broker 行为在本机无 rabbit 的情况下未跑通。
|
||||
这两类都要在联调环境补做,才算 §6/§7 的完整验收。
|
||||
- DDL 三个文件(`form-center/service/sql/answer_delivery_v2.sql`、
|
||||
`svc-hall/server/sql/fc_projection_and_apply_intent.sql`、
|
||||
`flow-center/service/src/main/resources/sql/add_flw_apply_request_identity.sql`)
|
||||
都是**非幂等**脚本,生产需人工执行;`ddl-auto=update` 的开发库会自动追加,两侧不能长期分叉。
|
||||
@@ -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 配置」 |
|
||||
@@ -0,0 +1,117 @@
|
||||
# 表单中心 · 消费应用授权与表单目录(v3,TASK_1)
|
||||
|
||||
对外契约的增补件,挂在《表单中心契约 v2》§9(对外读接口)之下。落地范围=**发现(目录)+ 授权(read/subscribe)+ 稳定引用(uuid+version)**,
|
||||
不含部门选型、不含 Apply 输入换轨(TASK_2)、不含事件投递(TASK_3)。
|
||||
|
||||
依据:VERIFICATION V03/V04/V07;原骨架 T01 与 T05 的确切版本读取部分。
|
||||
|
||||
---
|
||||
|
||||
## §1 调用身份:只有一个来源
|
||||
|
||||
`/oapi/form/**` 的身份 = 平台 `AppAuthFilter` 验签通过后写在请求属性 `yh.app.auth` 上的 **appKey**。
|
||||
|
||||
- 三件套 `X-App-Key / X-Timestamp / X-Signature` 缺失 → 平台链 401(`enforce-patterns=[/oapi/*]`),进不到业务层;
|
||||
- 属性拿到了但密钥不可用,或 `OpenApiAppClient.getAppByKey` 不能在UserBase确认同一appKey → `UNAUTHORIZED`;
|
||||
trusted-apps只提供验签密钥,不充当注册事实;注册查询不可达也拒绝,不把降级放行写成可用性保证;
|
||||
- 查询参数 `appCode` 与 `schId` **只是资源筛选与租户域**,不构成身份、不授予拥有权;
|
||||
- `app_registry` 仍是快应用目录(四业务列、零角色),本批一行不加,也不因 slug 同名自动认领任何资源。
|
||||
|
||||
## §2 落型:资源拥有者与消费租户分开
|
||||
|
||||
| 表 | 键 | 语义 |
|
||||
|---|---|---|
|
||||
| `form_ownership` | `uk_ownership_form(form_uuid)` | 这张表单是哪个 appKey 的资产;`form_school_id` 存绑定时表单自己的 `sch_id`(0=平台级) |
|
||||
| `form_consumer_grant` | `uk_grant(form_uuid, consumer_school_id, consumer_app_key)` | 允许"这个租户的这个应用"**读**和/或**订阅**这张表 |
|
||||
|
||||
- **默认拒绝**:`read_allowed` / `subscribe_allowed` 默认 0,没有隐式全集;
|
||||
- **读与订阅分权**:owner 天然能读自己那张表的 schema/答卷,**订阅必须另开一行 grant**(能看 ≠ 能往外推);
|
||||
- **租户维度不可省**:只按 appKey 授权会丢掉"读答卷仍以该校行集限定"这条。平台级表单(`form.sch_id=0`)对某校授予后,
|
||||
那一校看到的答卷仍只有自己的行(行集由调用侧 `schId` 条件保证,授权不放宽它);
|
||||
- **owner 由管理面写**:校管理员/超管在写入前核验 appKey 的 UserBase 注册身份(§1 同一张注册表,只判存在、不回显密钥),
|
||||
平台级表单的 owner 绑定**仅超管**可设——否则校管理员可以把平台资源认领给自己应用;
|
||||
- **统一拒绝口径**:跨校 / 缺归属 / 不存在 / 未授权 → 同一句 `表单不存在:<uuid>`,真实原因只进服务端 warn 日志;
|
||||
`answer` 端点被拒时回答 `答卷不存在:<answerId>`(答"表单不存在"等于同时确认两张对象都存在);
|
||||
- **撤销不删行**:`enabled=0` + `grant_version` 进位;重申同样是新纪元(版本再进位)。
|
||||
|
||||
DDL:`sql/form_consumer_access_v3.sql`(无物理外键,`form_uuid` 为逻辑引用;两表零存量、无需回填)。
|
||||
|
||||
## §3 目录端点与既有端点的授权
|
||||
|
||||
```
|
||||
GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize=
|
||||
→ data = { items:[{formUuid, code, name, appCode, version, audience[]}], total }
|
||||
```
|
||||
|
||||
- 过滤链:`sch_id ∈ (schId, 0)` ∩ `is_deleted=0` ∩ `status=已发布` ∩ **调用方可读**(owner ∪ 显式 read grant),
|
||||
**先过滤再分页**——`total` 是过滤后的条数,页数不随授权抖动;
|
||||
- `page` 从 1 起算(缺省 1),`pageSize` 缺省 20、**上限 100**(超限直接拒,不静默收窄);
|
||||
- `keyword` 对 `name`/`code` 做大小写不敏感的包含匹配,`appCode` 精确匹配(仅筛选);
|
||||
- `version`=当前发布指针,目录只出已发布行所以必非空。消费方选中后**成对保存** `formUuid + version`;
|
||||
- 不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是两个都合法的可选项(遮蔽规则属于按 code 的 resolve 入口)。
|
||||
|
||||
既有三个读端点同一批过 `requireRead`(签名通过不再等于全开):
|
||||
|
||||
| 端点 | 判定时机 |
|
||||
|---|---|
|
||||
| `GET /oapi/form/schema?uuid=[&version=]` | 解析**之前**(手里有 uuid,被拒与不存在同句) |
|
||||
| `GET /oapi/form/schema?code=[&appCode=]` | 解析之后、返回载荷之前(code 形态先要拿到 uuid) |
|
||||
| `GET /oapi/form/answer?answerId=` | 定位答卷之后,拒绝时回答"答卷不存在" |
|
||||
| `GET /oapi/form/answers?form_uuid=` | 游标解析之前 |
|
||||
|
||||
CAS 自用填报路径不受影响:服务端应用 ACL 不替代填报入口的会话身份与行级数据范围(契约 v2 §8 那套仍在会话身份上)。
|
||||
|
||||
## §4 消费方(svc-hall / flow-center)保存什么
|
||||
|
||||
- 两端各出一个**同源目录代理**(管理面调用,浏览器不持 appKey/secret):校域由 CAS 会话派生,不由前端传;
|
||||
- 只保存**服务器验证过的** `formUuid` + `formVersion`:不信自由输入的 `appCode`,也不信前端送来的版本号;
|
||||
- flow-center 发布期调 `schema?uuid=&version=`,**返回坐标与请求不一致即阻断发布**;已发布快照不改写,改关联走新版本;
|
||||
- svc-hall 事项保存时远端校验失败即整次保存失败(不写半个绑定)。
|
||||
|
||||
## §5 与旧默认开放行为的差异(上线必读)
|
||||
|
||||
改造前:`/oapi/form/**` 只要**应用签名通过**,对声明域(`schId` 本校 ∪ 平台级)内的 schema/answer/answers **默认全开**,
|
||||
没有表单级 ACL,也没有目录端点。改造后:**没有任何 owner 绑定或 grant 的表单,对外一律不可读**——
|
||||
包括此前"签名就能读"的存量表单。
|
||||
|
||||
⇒ **部署前必须逐张补 `form_ownership`(中心自有表)或 `form_consumer_grant`(跨应用消费)**,否则现有消费链路会读不到表。
|
||||
这是本批刻意选择的 fail-closed 取向(默认拒绝优先于兼容),不做"未配置=放行"的过渡开关。
|
||||
|
||||
**已知会在升级当场受影响的一条现成链路**:flow-center 的流程定义**发布期**快照(`FormCenterSchemaClient`,
|
||||
C1 第一刀)现在就在校域内按 uuid 读 `/oapi/form/schema`。中心升级后若没有给 flow-center 的 appKey(联调态 100019)
|
||||
补 owner 或 read grant,**发布会被拒**(表现为"快照拉取失败")。上线顺序因此固定为:
|
||||
① 中心侧先建好绑定/授权(按消费应用逐个授 read,flow-center 与 svc-hall 各一条起)→ ② 中心发布 → ③ 消费方发布。
|
||||
svc-hall 侧本次是新增链路(改造前不读中心),所以只有"选不到表"这一种表现,不影响存量事项。
|
||||
|
||||
其余对外可见差异:
|
||||
1. 新增 `GET /oapi/form/forms`(纯增量,不改变任何既有响应形状);
|
||||
2. `schema`/`answer`/`answers` 的**成功响应逐字节不变**,只是多了一层拒绝;
|
||||
3. 拒绝文案统一到"表单不存在/答卷不存在",原先"未发布/停用"这类差别信息只在**通过读权判定之后**才会给出。
|
||||
|
||||
## §6 管理面端点(守卫=校管理员 ∪ 超管,与 app 注册同一把)
|
||||
|
||||
```
|
||||
GET /api/form/admin/form-access/owner?formUuid=&schId= → {formUuid, formSchoolId, ownerAppKey} | null
|
||||
PUT /api/form/admin/form-access/owner?formUuid=&schId= body {ownerAppKey}
|
||||
GET /api/form/admin/form-access/grants?formUuid=&schId= → [{formUuid, consumerSchoolId, consumerAppKey,
|
||||
readAllowed, subscribeAllowed, enabled, grantVersion}]
|
||||
POST /api/form/admin/form-access/grants?schId= body {formUuid, consumerSchoolId, consumerAppKey,
|
||||
readAllowed, subscribeAllowed}
|
||||
POST /api/form/admin/form-access/grants/revoke?schId= body {formUuid, consumerSchoolId, consumerAppKey}
|
||||
```
|
||||
|
||||
- 校级管理员的管理域=本校:`consumerSchoolId` 不等于会话域即拒;超管可代某校授权/换绑;
|
||||
- `consumerSchoolId` 必须 ≥1(0 是资源域不是租户);`readAllowed`/`subscribeAllowed` 至少开一把;
|
||||
- appKey 写入前必须能在平台密钥注册表查到(否则"绑一个不存在的身份"会把资源挂到空气上),**响应不回显任何密钥**。
|
||||
|
||||
## §7 后续任务的唯一入口(不许另起一套 ACL)
|
||||
|
||||
`FormConsumerAccessService` 是授权判定的唯一出口:
|
||||
|
||||
- `ConsumerPrincipal resolveSignedCaller(String appKey)`(`ConsumerPrincipalResolver`)
|
||||
- `void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
|
||||
- `SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
|
||||
- `List<SubscriptionGrant> listEnabledSubscriptions(String formUuid, Long answerSchoolId)`
|
||||
|
||||
TASK_2(Apply 输入换轨)与 TASK_3(事件投递)只调这四个接口;`SubscriptionGrant.grantVersion` 必须随投递下发,
|
||||
消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。**
|
||||
@@ -0,0 +1,145 @@
|
||||
# 公共 schema 扩展契约草案(TASK_4)
|
||||
|
||||
日期:2026-10-22。状态:**文档检查完成,候选功能未实施**。本文不批准SUR-T19/SUR-T20完整功能,不取消OPEN-T19b代提交;不修改校验器白名单、题型注册、统计或流程引擎。普通表单、匿名、自填与原有代填能力继续按现有代码解释。托管SH首批仅本人,是独立的既有边界。
|
||||
|
||||
## 1. 证据基线与当前实现
|
||||
|
||||
本次重新读取各子仓库HEAD及当前工作区,未创建提交:
|
||||
|
||||
| 项目 | HEAD |
|
||||
|---|---|
|
||||
| FC后端 | `b2437a20742bfbfe38a564134904f3e0d7d931a3` |
|
||||
| FC前端 | `2e406a88b1d9e09b2ca54a8cf68a3726174f49c0` |
|
||||
| FL后端 | `2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f` |
|
||||
|
||||
HEAD不包含Task1~3及本轮精修的未提交内容。下列公共校验常量/函数锚点从当前源码重新摘录;FL快照字段搬运也与`git show HEAD:...FormCenterSchemaClient.java`对照。改动过的管理/提交路径引用当前工作区,并在根目录`docs/tasks/evidence/refinement-20261022/`留源文件指纹,不能把工作区源码宣称为HEAD中已实现。
|
||||
|
||||
FC / `src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java` / `FormSchemaValidator#validateSchema`、`#validateField`:
|
||||
|
||||
```java
|
||||
if (!root.isObject() || !root.path("groups").isArray()) {
|
||||
throw fail(null, "表单结构必须是 {\"groups\":[…]}");
|
||||
}
|
||||
rejectUnknownProps(group, ALLOWED_GROUP_PROPS, "分组属性");
|
||||
rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
|
||||
```
|
||||
|
||||
这三句分属根对象、group循环、field方法,不是连续源码。根对象当前只检查对象及groups形状,**没有统一拒绝全部根级未知属性**;这不意味着未知规则会执行。group仅允许key/label/fields;field允许以下19项:
|
||||
|
||||
```text
|
||||
key,label,type,required,pattern,min,max,maxLength,tip,placeholder,options,
|
||||
rows,defaultValue,accept,maxSize,maxFiles,precision,unit,searchable
|
||||
```
|
||||
|
||||
FC / 同文件 / `FormSchemaValidator#validateOptions`:inline选项对象仅label/value;字典引用仅source且匹配`dict:<code>`。字段valueSchema由读接口生成,不属于设计写入白名单。visible_when、matrix及任意脚本属性都不是已实现契约。
|
||||
|
||||
FC / 同文件 / `FormSchemaValidator#PHASE1_TYPES`:
|
||||
|
||||
```java
|
||||
public static final Set<String> PHASE1_TYPES = new LinkedHashSet<>(Arrays.asList(
|
||||
"text", "textarea", "number", "date", "select", "radio", "upload",
|
||||
"checkbox", "multiselect", "phone", "image"));
|
||||
```
|
||||
|
||||
address/user/users虽在14类冻结命名中,但未在一期放行。FC前端 / `src/types/formSchema.ts` / `PHASE1_TYPES`、`ALLOWED_FIELD_PROPS`及group/option/ref常量与之对应;`src/components/fields/index.ts` / `fieldWidget`、`widgetTypes`注册11类,app端组件仍有槽位限制,不能仅按目录存在就声明移动端完整可用。
|
||||
|
||||
FC / `src/main/java/com/yunhai/formcenter/common/FormValueValidator.java` / `FormValueValidator#validate`:
|
||||
|
||||
```java
|
||||
rejectUnknownKeys(data, fields);
|
||||
if (isEmptyValue(value)) {
|
||||
if (isRequired(field)) {
|
||||
throw fail(field, key, "为必填项,未填写");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
validateValue(field, key, value, dictCandidates);
|
||||
```
|
||||
|
||||
当前没有先执行visible_when的步骤。FC / `common/FormValueSchemaBuilder.java` / `withValueSchemas`在深拷贝上增加机读值形状,不改变版本schema原文;number输出number与边界,未生成precision对应multipleOf;多选输出array,不虚构uniqueItems;附件items闭集为id/fileName/key。机读片段不是完整的执行引擎替代物。
|
||||
|
||||
## 2. 扩展分类(未来候选,不是今天的兼容承诺)
|
||||
|
||||
| 类别 | 候选承载方式 | 未来识别策略 | 当前事实 |
|
||||
|---|---|---|---|
|
||||
| 不可执行display metadata | 独立版本化命名空间,如`extensions.display`,只放纯文本/枚举/布局提示 | 明确允许的读写链路原样保留;不参与资格、值校验、路由、hash派生规则;所有展示转义 | 当前field/group未知属性仍拒绝,设计器会按白名单序列化;没有通用保留保证 |
|
||||
| 可见性、必填联动、复合值约束等安全规则 | 明确schema契约版本及required capabilities | 写入/发布/提交/编辑/导入遇到不支持的必需规则必须拒绝,不能静默忽略 | 没有扩展能力协商或visible_when执行 |
|
||||
| 生成的机读描述 | valueSchema及未来投影说明 | 从已核验schema派生,不能作为客户端绕过后端校验的权威 | valueSchema当前仅在读取时生成 |
|
||||
|
||||
不使用eval、Function、JS脚本、任意表达式字符串或可调用URL。候选规则采用受限JSON AST,字段引用只能指向本表schema中的稳定key;运算符、类型、深度、节点数、引用数需白名单和预算。下列形状只是评审样例,**现有字段白名单不会接受它**:
|
||||
|
||||
```json
|
||||
{"key":"detail","type":"text","required":true,
|
||||
"visible_when":{"op":"eq","field":"kind","value":"other"}}
|
||||
```
|
||||
|
||||
将显示元数据和安全规则混放、遇到未知规则当作true、根据浏览器传来的visible结果跳过后端校验,均不属于候选契约。
|
||||
|
||||
## 3. 可见性、必填与隐藏值
|
||||
|
||||
未来统一顺序:按答卷formUuid/version取schema → 校验规则版本/类型/依赖 → 依拓扑顺序重算可见性 → 处理隐藏值策略 → 仅对可见字段执行required及值约束 → 附件归属/索引/存储。循环引用、未知key、类型错误、预算超限必须失败,不能转成“隐藏”。前端计算只用于体验,后端重算才是依据。
|
||||
|
||||
| 可见性结果 | required | 输入 | 候选处理 |
|
||||
|---|---|---|---|
|
||||
| visible | true | 缺失/null/类型约定的空值 | 拒绝必填空值;0与false不能当空值 |
|
||||
| visible | false | 空 | 允许约定空值;是否缺键规范化为null须写入版本协议 |
|
||||
| visible | 任意 | 非空 | 执行该题类型及值域校验 |
|
||||
| hidden | 任意 | 无值 | 跳过required,不误报漏填 |
|
||||
| hidden | 任意 | 有值 | 必须先定稿下表A/B策略,当前不选择、不实施 |
|
||||
| 规则无效/结果未知 | 任意 | 任意 | 拒绝,返回稳定规则错误,不当作hidden |
|
||||
|
||||
| 隐藏值候选 | A:拒绝携带 | B:服务端剥离 |
|
||||
|---|---|---|
|
||||
| 用户改上游答案使下游隐藏 | 需要客户端清理后重交;错误可诊断 | 服务端规范化后接收,须告知哪些字段被移除 |
|
||||
| 审计 | 记录拒绝字段名及规则版本,不把敏感values写日志 | 记录剥离字段名及规则版本,不能伪装成用户主动清空 |
|
||||
| 幂等/hash | 需明确同一请求的完整输入一致性 | 必须先规范化再生成存储hash,并区分原始请求指纹 |
|
||||
| 本人/管理员编辑 | 不允许通过隐藏字段偷偷补值 | 不允许以管理员权限跳过规则而无单独协议 |
|
||||
| 历史数据 | 不在读详情时破坏性重写旧答卷 | 同样不在读详情时自动剥离历史值;需要显式编辑/迁移审计 |
|
||||
|
||||
A/B是实施前的业务决策,不能在开发中默认选一种。条件依赖于另一个隐藏字段时,该字段是缺失、历史保留还是不适用,也须一并定稿;禁止靠真假值隐式强转决定。
|
||||
|
||||
## 4. 普通题型与版本来源基线
|
||||
|
||||
| 项目/类#方法 | 当前值/版本事实 | 对扩展的边界 |
|
||||
|---|---|---|
|
||||
| FC `common/FormValueValidator#validateNumber` | JSON number,支持min/max、precision;number空值用null,不能用空字符串代替 | scale/矩阵不能借number类型塞数组或对象 |
|
||||
| 同类 `#validateSingleOption` | select/radio为候选value字符串,存value而非label | 条件比较需比较稳定value,label变化不改历史语义 |
|
||||
| 同类 `#validateMultiOption` | checkbox/multiselect为value字符串数组;逐项查候选,当前无显式去重拒绝 | 数组不得被FL按标量比较;不宣称JSONSchema有uniqueItems |
|
||||
| 同类 `#validateAttachments`,`AnswerService`/`AnswerAdminService`附件归属检查 | upload/image为[{id,fileName,key}],服务端另核归属、key和大小/数量;值中不存URL | 隐藏附件的剥离、引用释放、历史下载权限须成对设计 |
|
||||
| FC `service/AnswerService#doSubmit` | 登录普通提交按code/appCode解析发布版本;匿名走resolvePublishedForAnon,实际版本由服务端解析,不接受浏览器指定版本 | 未来普通提交须明确页面解析到写入之间的升版策略;不把hosted的钉版保证推到全部通道 |
|
||||
| 同方法hosted分支,前端`views/Fill.vue#loadForm/#handleSubmit` | contextToken确定formUuid+formVersion,CAS确定本人及学校;后端按上下文读取确切schema | 扩展也必须按token版本,不能按中心最新schema计算可见性 |
|
||||
| FC `service/AnswerImportService#publishedFields/#importAnswers` | 字段取当前发布版,并逐行走AnswerService.submit;并非已冻结整次导入的schema快照;upload/image列属于UNSUPPORTED_TYPES,不支持导入附件 | 未来需把预检与实际导入钉在同一版本,并与自填/编辑共用规则;不得把当前导入声称覆盖附件 |
|
||||
| FC `service/AnswerReadService#myDetail/#editBySubmitter` | 按answer.formUuid/version读schema、编辑校验 | 不因当前表单升级而重解释旧答卷可见性 |
|
||||
| FC `service/AnswerAdminService#edit` | 按答卷原版本校验并重建索引 | 管理员编辑不能另写显隐判据 |
|
||||
| FC `service/AnswerExportService#fieldsForVersion/#fieldsByVersion` | 列头按当前版字段,逐答卷版本取字段解释值,缓存按version;标签/字典经expandSchema解析 | 并非已按历史版本并集导出全部已删题;schema原文钉版不等于外部字典label永远冻结 |
|
||||
| FC `service/AnswerStatsService#stats/#groupField/#currentSchema` | 总数来自权限范围查询;字段元数据来自当前发布schema,按索引分组;没有题目可见性分母 | 不可把现有总数当作“看见该题人数”;跨版本同key统计须显式分组/映射 |
|
||||
|
||||
表内路径均相对于form-center/service,前端另已标明。该表为源码基线,**本轮未执行上述题型的功能测试**。
|
||||
|
||||
## 5. 统计、导出和历史语义候选
|
||||
|
||||
未来分母至少分别表示:范围内答卷总数N、该题在其钉版schema存在的答卷E、规则计算可见的答卷V、有效作答A。缺失题/隐藏/可见未答/有效答案不能合并为一个null桶。V与A必须按每份答卷自己的schema版本和规则版本计算;不能把最新规则用于全部历史版本。
|
||||
|
||||
- 创建/匿名创建/本人编辑/管理员编辑/导入:同一规则服务与相同的空值、隐藏值策略;写后索引、outbox和hash基于实际存储值,不能只在前端隐藏。
|
||||
- 统计:题目存在比例用E/N,作答率用A/V(V=0时明确无定义而非除零);多选按答卷计可选次数,不能把选项次数当人数。矩阵每行分母、整题分母分别标注,不跨版本暗合并。
|
||||
- 导出:按答卷版本给出字段/行列标识及不适用标记;隐藏值是“不适用”还是历史保留受A/B策略与权限共同决定。不得在导出过程中顺手改写答卷。
|
||||
- 编辑:按原版本重算可见性;如果获批升级答卷schema,必须独立迁移任务、有原/新版本及审计,不混入普通编辑。
|
||||
- 状态修改/软删:已有revision/outbox流程继续使用;规则版本与可见性派生数据是否改变快照/hash需后续协议显式升级,不能原地变更fc.canon.v1。
|
||||
|
||||
## 6. 矩阵/量表候选
|
||||
|
||||
未冻结matrix/scale题型,也未实现渲染、存储、校验或统计。若获批,先定义稳定rowKey/columnValue及整题显隐;不能靠每个单元格的动态隐藏解释同一复合值。
|
||||
|
||||
```json
|
||||
{"satisfaction":{"row_service":"good","row_speed":"average"}}
|
||||
```
|
||||
|
||||
上例仅为object值的候选;也可评审显式array结构,但须择一版本化,不能同时接受多种模糊编码。unknown row/column、重复row、必答行缺失、额外键、空行与不适用行的行为均须有真值表。复合值到FL必须拒绝作为标量路由,或经获批、显式、版本化的投影变成单一键/数值/枚举。
|
||||
|
||||
## 7. 下一批实施与资源边界
|
||||
|
||||
下批应成对分配FC校验器/前端白名单/渲染/提交编辑导入/统计导出与FL快照兼容,文件与任务拆分见FL伴随文档。候选预算可先评审:规则深度8、节点64、引用32,矩阵行列各50、单题2500单元格;这些是待定资源上限,不是当前代码保证。超限必须静态拒绝,不执行递归无界计算。
|
||||
|
||||
实施前待定:A/B隐藏值策略、规则JSON版本、依赖隐藏字段语义、字典历史label、矩阵结构、投影命名/空值/值域及统计口径。未决项不影响本次只交付草案,但禁止据草案直接编码候选功能。
|
||||
|
||||
验收状态:文档边界/样例/真值表交叉检查完成;FormSchemaValidatorTest等题型测试未运行(遵循本轮用户仅编译/打包要求)。本次相关后端打包只证明编译及构建,不能证明visible_when/matrix支持。
|
||||
Reference in New Issue
Block a user