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

8.8 KiB
Raw Permalink Blame History

表单中心 · 消费应用授权与表单目录(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 必须随投递下发, 消费方据此识别"这份授权已作废"。撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。