Author SHA1 Message Date
chenxing c63fbcd299 test(ci): 门禁选择器纳入 MQ 定向用例(5 类 38 例)——zcode 审查轮补齐,远端 CI 覆盖本地 97/97 的 FC 半边 2026-10-23 16:44:36 +08:00
chenxing 0d408c4cfd fix(mq): B-04/B-05/B-09/N-1 独立修复——最终凭据属性链+注册映射核验、mq-enabled 默认关、代际回调裁决(ACK 不覆盖 NO_ROUTE)、v1 事件显式 JSON 转换+独立开关 2026-10-23 16:36:54 +08:00
chenxing 1717f4c47e Run mandatory RC gates with isolated CI failure checks
build / build-push (push) Successful in 3m13s
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:19 +08:00
chenxing 6ff39b0cec Define authoritative reconciliation acknowledgements
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:16 +08:00
chenxing c6e061815a Enforce exact published form version contracts
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:13 +08:00
chenxing e39164a2bd Preserve reviewed existing gate prerequisites
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:10 +08:00
chenxing 256735fb43 feat(openapi): 对账派发改 by-key 注册表寻址(appCode 白名单+fail-closed),显式 targets 例外保留
build / build-push (push) Successful in 1m27s
2026-10-22 17:14:22 +08:00
chenxing a00f97f0ec fix: answerReconcileProperties @Bean 拆到无依赖独立配置类——Boot 2.6+ 默认禁止一切循环引用,字段注入不豁免(160 复验仍拒启)
build / build-push (push) Successful in 50s
已用 MqDisabledContextIT(@SpringBootTest 全上下文)验证装配通过:Tomcat 起来+健康检查 UP。
2026-10-22 10:44:04 +08:00
chenxing 885b47226f fix: 对账配置类改字段注入,解开 Configuration→Service→Properties 构造环(160 部署实测启动失败)
build / build-push (push) Successful in 1m15s
2026-10-22 10:33:46 +08:00
chenxing d7473112b0 t1~t3落地+10-22精修: 消费授权v3(owner/grant/订阅/目录)+答卷投递v2(可信提交上下文/快照/幂等关联)+答卷对账v1(清单/派发/租约/结果)
build / build-push (push) Successful in 1m26s
含10-22精修六类修复: 续租renewRunning/结果INCOMPLETE_RESULT门/终态TASK_TERMINAL/liveGrant复用ACL/hosted幂等重放绑定/错误明细整条重序列化。
合规审计与证据: 工作区docs/tasks/REFINEMENT_AUDIT.md + evidence/refinement-20261022/(patch+指纹)。SQL未执行、测试未运行(仅-skipTests编译)。
2026-10-22 10:02:57 +08:00
ops 4b76d616dd fix(ci): 构建容器名唯一化(避免并发构建互相 rm -f 致 exit 137) 2026-10-21 02:26:47 +00:00
ops f8a7bdd4e4 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:58 +00:00
chenxing b2437a2074 S8收口:audience数组漂移闸断言入册(byUuid/byCode两寻址同集合)
build / build-push (push) Failing after 2m8s
2026-10-20 22:37:39 +08:00
chenxing be2752ed4d t18:批量导入——逐行走 submit 管线 + C13 确定性幂等键 + C14 表头先校验与逐行结果
- 新 AnswerImportService:模板(两行表头:当前发布版字段 label + 固定第一列「被记录人ID」,第二行说明)
  与导入两端点都挂在 /api/form/admin/data 下、守卫与导出同一把 requireData(该表单 ALL 档)。
- 每一行拼成一次普通提交请求进 AnswerService.submit(V60 采集管线唯一):值校验、按钉住版本解释、
  限次、截止、幂等、索引、MQ 事件与手工填报逐字同一套;导入侧不写第二份校验,只做
  "读 xlsx → 按 label 映射列 → 最小类型转换(number/多选数组,其余留字符串)→ 逐行收集结果"。
- 坏行不静默:逐行 {rowNo, ok, answerId|error},失败原因是管线原话不改写;rowNo 用文件物理行号,
  "第 7 行"能直接指到那一格。附件类字段逐行点名"批量导入不支持上传",不静默丢列。
- 整批在写库之前拒的三件(C14):重名 label(模板生成侧同样先判,不给带歧义的模板)、
  表头有当前发布版不存在的列、行数超 1000(超限整批拒不是截断)。
- 幂等键 C13 定稿:sha256("import:" + 表单 uuid + "|" + subject + "|" + 规范化值文档) 前 40 位。
  批次串取表单坐标——请求级随机串等于没有幂等,文件字节摘要会让"改一个字重传整份"把未改的行
  全部重插(1000 行修 1 行 = 999 条重复);也不含行号。前缀让导入键与手工填报的客户端 uuid 键不相交。
  登记问题单 P128(已按本单回写契约 §5.1/C13)。
- FormAnswerReadController 加 GET /import-template(xlsx 流,原生 fetch 侧配套)与 POST /import(JSON 信封)。

测试 AnswerImportIT(V60/V74)12 例:模板形状与说明行、未发布两侧拒、导入行带 form_uuid+version 且
searchable 字段照写 answer_index、四类坏行逐行原话 + 物理行号、被记录人非整数逐行拒、
代录行 subject/submitter 归属正确且非 ALL 档进不到服务、同一文件重传逐行原 answerId 且改一格只新增那一行、
限次照挡、三类整批拒且零写入。门禁定向 12 例全绿(s7/import-it-4.log,MVN_EXIT=0)。
2026-10-20 17:40:16 +08:00
chenxing b3478afddd t17:oapi v2 增量拉取 + RabbitMQ 事件(可选依赖、信封十字段闭集、软删防回归断言)
- GET /oapi/form/answers(契约 §6.1/C9/C11/C12):域校验走 readDomainForm(他校与不存在同一条
  "表单不存在",V54;软删表单照读,§5.4/P32),行集恒带 sch_id。增量口径按冻结状态二分且不接受混用
  (混用给可诊断拒绝,而不是静默返回一个"看起来正常"的空窗口):
  冻结=append-only + after=answer_uuid(服务端按 uk 换算库内 id,id 绝不出响应,V53,墓碑照常下发);
  非冻结=updated_since + 复合键 (update_time, answer_uuid),续拉游标 {ISO8601}|{answer_uuid}(P126)。
  limit 1~2000 越界拒不夹;nextCursor 只在这一页填满时给(宁可末尾多要一次空页,不可少要一次)。
- 行字段是 §6.1 的 15 键闭集(逐字段 @JsonProperty,外层 rows/nextCursor 按 C9 原文 camelCase——
  命名差异是契约原文,不加"顺手统一"的适配层);meta.ip/ua 只匿名行有值(C12),登录行两项 null。
- 时间线格式降到整秒并对齐列精度(DATETIME 秒粒度;带纳秒的游标与列值比不出等值,
  同事务重读还会经一级缓存拿到内存对象):取舍方向是"宁可重复投递也不漏行",
  重复投递正是 C9 明写的 at-least-once(P127)。IT 侧 flush+clear 把读取摆回生产形态。
- 新 FcEventPublisher(§6.2/V62/V63/V64):RABBITMQ_HOST 未注入=整体 no-op + 启动一条说明日志;
  RabbitTemplate 走 ObjectProvider(可选依赖不该在缺 bean 时拖垮上下文);exchange fc.events.v1
  惰性 durable topic 声明(声明失败放回标志位允许重试);routing key fc.answer.{app_code}.{action};
  afterCommit 发布且信封在注册前按实体快照组好;发布失败只 warn 不抛(没有 outbox、没有死信)。
  信封十字段闭集、不含值文档(裁决 1:MQ 是通知通道不是数据通道)。
- 发布点三处:submit 写库段后=created(代录/导入同管线自动获得);两处编辑写库段=updated
  (能走到即说明未开冻结,V61 已在前面拦);两处软删实体 save 后=tombstoned(公共出口是发布器那一个方法)。
- 依赖与配套:pom 加 spring-boot-starter-amqp(BOM 管版本);yml 的 spring.rabbitmq.* 全从
  RABBITMQ_* env 现读,并关 rabbit 健康探针与 Boot 的 RabbitAdmin(dynamic=false)——
  后者启动就拨号声明拓扑,无 broker 环境实测把上下文启动拖到分钟级,拓扑已由发布器自己声明。

测试:OapiAnswersIT 8 例(闭集无 id、口径二分互不混用、limit 边界、after 翻页+重放+墓碑可见、
V70 软删前移 update_time 才让墓碑进窗口、复合游标带 uuid 尾、他校同文面、匿名行 meta)
+ FcEventPublisherTest 5 例(V63 十字段闭集、routing key、未配置即 no-op、afterCommit 之前不发、
异常被吞)+ MqDisabledContextIT 2 例(无 broker:enabled()=false 与 /actuator/health 仍 UP)。
门禁:定向 15 例全绿(s6-gate-2.log),本段另跑过一次全量 288 例全绿(mvn-full-2.log,
按协调口径后续段不再连发全量,全量留 S8 收口)。差额 P126/P127/P128。
2026-10-20 17:17:40 +08:00
chenxing c41ffebb95 t16:app 注册表——四业务字段零角色 + C17 CRUD 端点族 + 在用禁删 + 入口链接生成
- sql/t16_app_registry.sql:§17.2 建表(uk_app_code 全库唯一、form.app_code 逻辑引用不建 FK)。
  本机核对:dev 库 ddl-auto 建的表与脚本列型逐列一致,且 Hibernate 尊重显式索引名(无漂移);
  脚本另在临时库实跑一遍校验语法后已清理。
- 实体 AppRegistry:app_code/sch_id 均 updatable=false——code 是入口链接与 routing key 的组成段,发布即冻结。
- AppRegistryService 四条写侧规则:C10 格式 + 保留字 form-center(大小写变体也算撞名);
  撞 uk 前先按 code 点名(在用=已存在 / 只剩软删行=编码不复用,都是可诊断 CUSTOM(5) 而不是库内 500,P125);
  删除保护按 countLiveByAppCode 全库统计(code 全库唯一,他校还在用同样算在用,取窄的一边);
  删除=实体 save 改标记(软删全仓保持实体写,bulk UPDATE 会绕过审计监听器)。
- 域规则:清单=本校∪平台级;改/删只动 sch_id==本次域的行,他校行按"app 不存在"回答。
  超管建平台级需显式 schId=0,与两表端点同一条代查口径,零新增判据。守卫复用 requirePlatformAdmin(C4③)。
- 入口链接由后端生成:{context-path}/front/#/app/{code}/,本地无 context 段时回落 /#/app/{code}/
  (同 host.config.ts 的推导),前端只补 origin。
- 新增 GET/POST/PUT/DELETE /api/form/admin/apps。

测试 AppRegistryIT(V59/V65/V69,8 例):反射断言持久字段恰好 appCode/name/schId/isDeleted 且无
role|perm|admin|user|auth 形态列名;保留字与 6 种非法 slug + 2/64 位边界;重复注册不撞库;
本校∪平台级清单与链接后缀;普通教师 FORBIDDEN、他校"app 不存在";改名只改 name;
草稿态即算在用(msg 带张数),表单软删后才让删;软删后 code 不复用。
门禁 mvn -B -o clean test → Tests run: 273, Failures: 0, Errors: 0。
2026-10-20 14:30:10 +08:00
chenxing 3dac43dcfc t15:匿名通道落地——/anon/form 三端点 + HMAC cookie 防重 + 提交管线分通道
- 新 controller/anon/AnonFormController(RequestMapping /anon/form,不带 /api:CAS starter
  的 6 个 Filter 只挂 /api/* 且无豁免位,A11):resolve/submit/upload 三端点,签名里不出现 BoaUser。
- FormDefService 抽出 publishedOf 共用生命周期判定,新增 resolvePublishedForAnon/resolveForAnon:
  全域取"已发布未删 + audience 含 ANONYMOUS",非匿名/草稿/停用/不存在/跨校歧义同一句"表单不存在"
  (C6/V54 防探测;歧义 fail-closed 见 P119);出参复用登录面同一个组装实现。
- AnswerService 提交身份收成 SubmitIdentity(ofSession/ofAnon 两个工厂,通道由入口显式声明而不是
  从 user==null 反推):匿名面 sch_id/submitter_sch_id=表单归属校、submitter_id=0、subject_id 留 NULL、
  不进人群门也不占 fill_quota,值校验/附件/钉版/报名号/索引一段代码共用(V56/V57/V60)。
- 新 AnonReplayGuard:cookie 值=服务端 HMAC 时间戳(密钥复用 yh.app-auth.app-secret,零新增配置项、
  零服务端会话状态),窗口按 form.anon_cookie_minutes;伪造/篡改/跨表单按"没带 cookie"放行,
  命中=CUSTOM(5) 文案含剩余分钟;只在真提交成功时签发,幂等重放优先于节流且不续窗口(C7)。
- AnonModels 分 DTO:匿名请求体结构性没有 subjectId;Context 只从 HTTP 请求取(IP/UA/节流剩余秒)。
- FormFileService 加 uploadForTenant(schId, file)(匿名上传落表单归属校,否则提交侧附件归属查不到);
  五条硬约束一条不分叉。留痕按列宽截断落 answer.anon_ip/anon_ua(列在 t12 DDL 已预建,本批启用)。

测试:新增 AnonFillIT(V67,8 例:防探测文案等值、落库形态、平台级租户 0、窗口内二发 CUSTOM(5)、
伪造无效、幂等命中优先、窗口按表单配置、上传落档校)+ AnonReplayGuardTest(7 例:窗口边界与密钥缺失
fail-closed)。门禁 mvn -B -o clean test → Tests run: 265, Failures: 0, Errors: 0。
落地差额登记 P119/P121/P122/P123(详见 docs/T12-T18-report.md §1)。
2026-10-20 14:04:42 +08:00
chenxing e4bf960e1b t14:模块行退役 + 菜单自解析——守卫换 C4 三段角色门,新增 managed/capabilities 端点
- FormAdminGuard 整体重写:requireCreate(超管∪校管理员,G1)/requireDefinition(+该表单创建人)
  /requireData(该表单 ALL 档)/requirePlatformAdmin(校管理员∪超管);答卷寻址的入口
  先由 uuid 反查所属表单再判同一道门(requireDataByAnswer/requireDataByAnswers);
  租户域推导(超管可指定含 0、校级只能本校、传 0 即拒)一字未改
- 摘除 ModuleTreeSource/PlatformModuleTreeSource/FormModuleKeys 与 GetUserMod 端点
  (§19 作废-10:中心不再消费授权树,B14 缺口与 H4 依赖随之消失)
- 新增 GET /api/form/my/managed(C3:创建人 ∪ form_admin,role 标来源)与
  GET /api/form/my/capabilities({schoolAdmin},"系统设置"入口唯一数据源);
  managed 行额外带 formUuid/status(P120:只给 C3 的"fillable 行+role"点不进设计器/数据页)
- 两表/app 注册/GetSchoolAll 守卫切到 requirePlatformAdmin;字典同样吃这一道门
- h2_userbase_app_and_modules.sql 文件头加"整份作废、不再在任何环境执行"注记(正文保留供追溯)
- 测试:FormAdminGuardTest 按三段门重写(含批量入口跨表逐张判 ALL、fail-closed 与域推导);
  FormBaseControllerTest 改成"GetUserMod 端点反射级不存在"断言;AdminDomainIT/MyAnswerIT/
  AnswerAdminIT/AnswerReadIT 的模块行断言换角色门载体;PerfTreeCallsProbe(P44 取树次数)
  随度量对象消失而删除
2026-10-20 13:20:58 +08:00
chenxing f10b367bbc t13:所有权模型切换——audience 多值门 + ALL/SELF 两档 + 冻结开关,三层模型与身份矩阵出清
- DDL sql/t12_access_simplification.sql(非幂等,先查列):form.audience TINYINT→VARCHAR(64)
  + 存量迁移、form 加 creator_id/frozen/anon_cookie_minutes、answer 加 anon_ip/anon_ua
  + idx_answer_form_utime;实体与 @Index 同步(本地库已备份并手工执行)
- audience 多值(C1/C2/C5):FormEnums.Audience 扩 ANONYMOUS + parse/join/namesOf 单点工具;
  配置通道收数组并加跨字段约束(含 ANONYMOUS 时 fill_limit 必 0,CUSTOM(5));
  FormView/ResolvedFormView/FillableRow/oapi SchemaView 出参一律数组(flow-center 需通告)
- 两档范围(§2.3/D4):DataScopeService 重写为 {all,self} + 平台级表单的租户限定变体(D9),
  身份默认矩阵与回落分支删除;5 实现器收敛为 ALL/SELF 两个,SCHOOL 谓词并入 ALL 变体、
  TEACHING_CLASS 摘除;AnswerScopeQueryBuilder 改吃查询规格(V55 唯一出口不变)
- audience 门替第 0 关(§3.1):AnswerService 的 fillAccess.resolve+requireFillEntry 换成
  人群门,requireSubject 指定权收敛为"该表单 ALL 档";FillAccessService 全类删除;
  代录/导入不过人群门(P116 用户裁决:§3.4 与 §5.1/V73 冲突取"功能可用"一侧)
- fillable 行形状(C2):删 canFill/myTier,入集=身份类型 ∈ audience(与提交同一公式)
- 冻结开关(§4.2/V61):config 通道 + 两条编辑通道同关,文案指路"撤回后重新提交";
  新增 AnswerFrozenIT 钉双通道与修正路径
- 创建人识别(C16):creator_id 建列 + create_by 账号名兜底(兜底取会话线程账号,
  与 create_by 同一事实源,不拿 BoaUser.account 跨系统比串)
- 测试:FillAccessServiceTest 整类删除(V48 作废)、AnswerFillGateIT 按 v2 语义重写(V66)、
  DataScopeServiceTest 按两档重写、AdminScopeIT/AnswerExportIT/ReadFixture 造数改自填路径
2026-10-20 12:54:07 +08:00
chenxing fa4318cfc2 t12:FormAccessPolicy 门面收敛——会话推导六处+角色常量三处收编,零语义变化
会话校推导/身份角色并集/超管判定收进门面单点,角色常量与校领导临时清单 {2,7}
同址(V58 单点);判定逻辑与拒绝文案逐点沿用原实现,既有 272 例测试一字未改全绿。
清单外补件:FormFileService 的 requireSessionSchId 是 B3 未列的第七处,一并收编。
2026-10-20 12:00:46 +08:00
chenxing 13abc2dbc7 t11-schema: /oapi/form/schema 支持按 form_uuid 查询(uuid 优先 + version 精确取行)
build / build-push (push) Failing after 2m2s
flow-center 在流程定义发布期拉契约快照的数据源(v2 方案 §4.4),code 查询路径逐字节不变:

- Controller:新增可选 uuid(32 位串)/version(Integer),code 由必填放宽为可选;uuid 与
  code 同传时 uuid 优先(类头 javadoc 写明形态与用途);响应形状零改动,仍是同一个 SchemaView。
- FormDefService.resolvePublishedByUuid(纯新增):域校验复用 readDomainForm(本校 ∪ 平台级,
  他校与不存在的 uuid 同一回答,不给探测面);不给 version=入口口径——要求 PUBLISHED 且
  currentVersion 非空,文案与错误码族对齐 resolvePublished,并额外挡软删行(code 形态取域
  SQL 带 is_deleted=0,不挡就是同一张表两份可读集);给 version=溯源口径——pinnedForData
  同款,不吃生命周期、不过滤软删,缺行给 PARAM「版本行不存在:<appCode>/<code> v<n>」。
- OapiFormService:五参重载只做入口分流,原三参方法体一字未动(numstat 删除行=0);只给
  version 不给 uuid 显式拒(静默忽略会让调用方误以为拿到的是那个版本)。SchemaView 组装段
  按任务书 W2-1「原方法体不重构不挪动」保留两份,靠 F1 的 groups 逐字比对作漂移闸。

问题单 P106~P111 已回填执行手册 §5;《契约》§9 增补两形态并新开 §9.1(参数表/示例/错误码/
「uuid 形态不经遮蔽」与「两条分支口径不同源」两段说明)。

验证:mvn -B -o test 全量 272 例绿(0 failures / 0 errors,BUILD SUCCESS)。
新增 11 例=OapiFormServiceIT F1~F7(uuid 已发布版/精确历史版/平台级/防枚举同文案/
未发布·停用·软删/版本行不存在/uuid 优先)+ OapiFormControllerTest 4 例(不传 code 不 400、
version 绑成 Integer 且缺省 null、五参原样透传)。基线 261 → 只增不减;
F8 回归=既有四个 code 形态用例一字未改(该测试文件 diff 为 +144/-0)。
2026-10-19 19:56:46 +08:00
chenxing 7af6a98fc0 fix(post-p95): 跨版本编辑必挂/编辑不吃截止闸/自填 subject 归一/报名号并发重号
P95 落地后的独立修复批,四条存量逻辑硬伤(填报入口层不动):

- 跨版本编辑必挂:客户端按当前发布版渲染、服务端按钉住旧版校验,升版后编辑永远
  400(含未知字段/缺必填)。改为编辑态吃 /my/detail 的钉住版 groups。
- 编辑不吃截止/停用闸:先占位、截止后随便改可绕过 deadline。editBySubmitter 补
  截止/停用/已删三闸,与 submit 同口径。
- 自填 subject 落 NULL:subject_id 为空的自填行进不了"关于我的"(/my/about 与
  ABOUT_ME 谓词都按 subject 查),V18"含代填"实际退化成"仅代填"。落库归一为
  coalesce(subjectId, submitterId);限次键本就是 coalesce,不受影响。
- answer_no 并发重号:count+1 的 count 与 insert 之间有并发窗口,报名号是对外凭证。
  加 uk_answer_no 兜底 + 撞号重算序号重试(3 次封顶),按异常消息列名区分撞的是
  报名号还是幂等键(MySQL/H2 通用)。

语义变更备案:自填行 subject 由 NULL 变本人,读矩阵"学生x面向学生=关于我"这一格
此后含自填行,可见集变大(DataScopeService/矩阵规则一字未动,变的是数据形状)。
三处断言随行为更新:AdminScopeIT、AnswerReadIT、MyAnswerIT(+ReadFixture 注释)。

验证:mvn 261 绿(+2 新用例:并发报名号不重复、编辑闸三态)。
2026-10-16 15:04:20 +08:00
chenxing a02f47ad5c feat(p95-fill): 填报入口收回中心——三层角色模型 + 提交第 0 关 + 我可填清单
build / build-push (push) Successful in 58s
任务书 P95 W1/W2/W3/W7。发一张表单原本要 UserBase 超管加一条 /f/{code} 模块行并组授权,
实测成本不可接受(10 张表=10 次跨系统操作),人群判定收回表单中心自解析。

- FillAccessService:audience × 会话身份(roleIds ∪ currentRole)× 管理员两表 → 层集合
  ADMIN/MANAGE/FILL/空=NONE,谓词 canFill/canProxy/isAdmin + topTier;与 DataScopeService
  读矩阵、FormAdminGuard 管理端入口三条线正交,结论互不供给
- AnswerService.doSubmit:钉版解析之后插第 0 关(先鉴权后业务,幂等重落在闸门后);
  requireSubject 吸收行为表——教师×面向学生只能代填本校学生(P33 existsInSchool 保留),
  层③代填仍沿 canManage 现路径,层①普通成员代填照旧拒
- GET /api/form/my/fillable:会话校 ∪ 平台级取域、复用 FormDefService.pickByShadowing 做
  (app_code,code) 遮蔽去重、仅已发布未软删、NONE 不入集,行带 canFill + myTier 两件事,
  deadline 升序 null 在后;新增 MyModels.FillableRow
- h2 登记脚本快应用段 5→4 行:删 /f/ 填报行与学生组授权,保留应用行/管理页面行/
  path 空导入导出锚点/学校开通;H2 冻结延续,本轮未在任何环境执行
- 测试:FillAccessServiceTest 16(V48)+ AnswerFillGateIT 14(V49)+ FillableListIT 10(V50),
  mvn -B -o clean test 全量 259 绿(教师×面向学生的造数据通道按新口径改代填,
  读矩阵与限次等断言判据一字未改)

DataScopeService 与 FormAdminGuard 两个文件本轮未被改动(红线 1/2)。
2026-10-16 12:43:31 +08:00
chenxing dc76574118 feat(t10-admin): 管理员体系——校级管理员+单表单管理员+锚点退役
build / build-push (push) Successful in 2m12s
- 新增 school_admin/form_admin 两表(sql/t10_admin_domain.sql,含 answer.submitter_sch_id 加列)与 AdminDomainController/AdminDomainService/SchoolUserTreeService
- DataScopeService 范围体系按 P80 验收口径落地:入口只认模块行、查询域=会话校、两表只升范围;新增 SchoolScopeImplementor
- fc:scope:all / SCOPE_ALL_CUSTOM_ID / anchorCustomIds / MODULE_DICT 锚点全链路退役,全仓引用归零;h2 登记脚本 6→5 行(快应用段逐字未动)
- AnswerScopeQueryBuilder 校域不变式:answer.sch_id 恒=会话校,基础过滤 sch_id+app_code+code+is_deleted 不变
- FormAdminGuard:targetSchId=0(基础表单)仅超管可维护;FormAdminController/AnswerReadController 等按管理员范围收口
- 测试基线 190→219/0/0/0:DataScopeServiceTest 22、FormAdminGuardTest 14、AdminScopeIT 8、AdminDomainIT 10、AnswerReadIT 改写 1、FormBaseControllerTest 7 等
2026-10-15 20:25:03 +08:00
157 changed files with 20356 additions and 1815 deletions
+42 -10
View File
@@ -9,13 +9,17 @@ name: build
on: on:
push: push:
branches: [ main ] branches: [ main, codex/core-rc-20261023 ]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
cache_bust: cache_bust:
description: '构建前清空 maven m2 缓存' description: '构建前清空 maven m2 缓存'
required: false required: false
default: 'false' default: 'false'
tests_only:
description: '只验证测试和 JAR,不构建推送镜像或通知建档'
required: false
default: 'true'
jobs: jobs:
build-push: build-push:
@@ -25,7 +29,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl(alpine) - name: 安装 git / curl(alpine)
run: apk add --no-cache git curl run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -33,6 +40,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA"
git -C /tmp/src checkout --detach FETCH_HEAD
test "$(git -C /tmp/src rev-parse HEAD)" = "$GITHUB_SHA"
echo "CI_SOURCE_SHA=$GITHUB_SHA"
cp -a /tmp/src/. . cp -a /tmp/src/. .
rm -rf /tmp/src rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
@@ -55,20 +66,40 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存) - name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: | run: |
set -e set -e
docker rm -f mavenbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
docker run -d --name mavenbuild \ # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
cleanup_ci() {
result=$?
trap - EXIT
docker rm -fv "$BUILD_CONTAINER" >/dev/null 2>&1 || true
docker info >/dev/null 2>&1 || { echo 'CI_CONTAINER_CLEANUP=UNVERIFIED' >&2; exit 1; }
if docker inspect "$BUILD_CONTAINER" >/dev/null 2>&1; then
echo 'CI_CONTAINER_CLEANUP=FAILED' >&2
exit 1
fi
echo 'CI_CONTAINER_CLEANUP=PASS'
exit "$result"
}
trap cleanup_ci EXIT
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \ -v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/ docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml" docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package' docker exec "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像 - name: 构建并推送镜像
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
run: | run: |
set -e set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8) TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
@@ -79,6 +110,7 @@ jobs:
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
- name: 通知 projman 建档(部署由运维手动触发) - name: 通知 projman 建档(部署由运维手动触发)
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
run: | run: |
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
TESTS=FcAnswerDeliveryTest,FcDiscoveryAccessTest,FormConsumerGrantPushGateTest,FcReconcileTaskTest,OapiFormControllerTest,FcEventPublisherTest,AnonReplayGuardTest,AppCodeReconcileRoutingTest,OapiSchemaPublishedVersionTest,AnswerDeliveryBrokerRaceTest,AnswerDeliverySourceIdentityTest,AnswerDeliveryMqContractTest,FcEventConverterContractTest,FcEventJsonPublishTest
# Surefire 的选择器部分匹配也可能成功;每个指定类必须随源码存在。
oldIFS=$IFS
IFS=,
for test in $TESTS; do
[ -n "$(find src/test/java -type f -name "$test.java" -print -quit)" ] || { echo "Missing required test class: $test" >&2; exit 2; }
done
IFS=$oldIFS
if [ "$#" -eq 0 ]; then set -- test; fi
exec mvn -B "$@" -Dtest="$TESTS" -DfailIfNoTests=true -Dsurefire.failIfNoSpecifiedTests=true -DforkCount=0 -Dspring.jpa.hibernate.ddl-auto=validate < /dev/null
+342
View File
@@ -0,0 +1,342 @@
# 答卷投递 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,只消费它。
**现行主链(2026-10-22 决策,10-23 复核)**:SH 以签名收据 + HTTP 确认建件,不依赖 MQ 快照
投递行、broker confirm 或 MQ 消费成功;`requireRead + requireSubscribe` 仍是可信上下文的授权前提。
下文 outbox/MQ 章节描述可选推送通道,不能据此把 MQ 重新设为建件必选项。
MQ 隔离、`push_allowed` 全量契约与投递状态覆盖修复另案交付,不能由本轮 FL 门禁变绿推定已完成。
---
## 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.3a flow-center 管理面绑定失败(2026-10-23 R2/R1)
关联表单的流程定义必须绑定 `formUuid + formVersion`;保存显式关联、发布与启用都按
`schema?schId=&uuid=&version=` 核验同一明确发布版本。UUID-only 定义没有长期回退,缺版本必须重新选择。
独立无表单流程保留;孤立版本号拒绝。FC 精确 schema 只允许已发布过的版本(`publish_time != null`),
拒绝草稿;历史发布版不要求是 currentVersion,也不改变历史答卷读取路径。
FL 信封稳定码:`46101 VERSION_REQUIRED`、`46102 INVALID_COORDINATE`、
`46103 SCHEMA_UNAVAILABLE`(可重试)、`46104 SCHEMA_CONTRACT_VIOLATION`、
`46105 RESOURCE_UNAVAILABLE`、`46106 ACCESS_DENIED`。除 46103 外均不能盲目重试。
FC schema 业务码 2 将不存在/不可读/未发布统一拒绝,FL 映射为 46105,不按文案探测资源存在性。
详见 [FL 精确版本契约](../../../flow-center/service/docs/form-binding-contract.md)。
### 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` 的开发库会自动追加,两侧不能长期分叉。
## 11. R3/R4 对账结果回执裁决(2026-10-23 收口)
本节是对账 `submitResult` 协议,不修改 MQ publisher confirm/return 或投递状态机(B-09 另案)。
回执新增 `decision`、`reportAttempt`(本次回报轮次)、`resultState`(本轮已接受的结论);
既有 `attempt` 继续表示中心当前派发轮次,`taskState` 表示中心任务状态,不能单凭它推断本次旧回报被采纳。
旧回报永不推进中心新轮。终态权威必须由 `CENTER_TERMINAL` 显式声明,不以 stale=true 附带的诊断状态冒充。
| 条件 | decision | FC 写入 | SH 处置 |
|---|---|---|---|
| 当前 attempt 首次合法结果 | CURRENT_ACCEPTED | 持久结果;RUNNING 续租,终态按已有规则收敛 | 按 resultState 回填;RUNNING 保留租约,失败不能变成功 |
| 当前 attempt 重复已终结结果 | CURRENT_DUPLICATE | 不覆盖首次结果、不重复计数 | 使用首次 resultState;不采信重报自己宣称的新结果 |
| 旧 attempt,中心尚非终态 | STALE_RETRY | 仅归档;已有终结结果不覆盖;不续新轮租约、不推进任务 | 清本地租约,QUEUED 等重派/重试;不把诊断 taskState 当本轮成功 |
| 中心已终态(包括旧 attempt 回报) | CENTER_TERMINAL | 不改中心终态与新轮结果 | 身份、轮次回声验证通过后镜像中心真实终态;FAILED/PARTIAL/CANCELLED 不伪装 COMPLETED |
| 回报超出已派发 attempt | HTTP 业务拒绝 | 零结果/状态变更 | 保留可重试/人工诊断,不记成功 |
| 发送异常、不可达、拒绝、空或不匹配回执 | 无有效确认 | 不推定已成功 | QUEUED + pendingReportState,仅重报;有界退避,耗尽 FAILED |
| 缺新 decision 的旧中心回执且 stale=true | 保守兼容 | — | 保持 QUEUED,不信任附带终态;下一次完整回执再收敛 |
当前/重复成功回执必须匹配 taskId,reportAttempt 若提供必须等于本地发送 attempt。
新协议 current 决策不得确认其他轮次;CENTER_TERMINAL 的中心 attempt 不得低于本地执行轮。
乱序旧回执在本地持久化时仍受 work_version 乐观锁保护,不覆盖重派或新执行轮。
有效租约 RUNNING 的重派不改变执行 attempt、租约、检查点或 work_version;过期(含相等)后才能重新排队。
接收与领取均用同一任务行的悲观锁;领取时重新核对状态和退避时刻,陈旧扫描候选不能双领。
+376
View File
@@ -0,0 +1,376 @@
# 答卷差异对账 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 配置」 |
## 2026-10-23 R3/R4 收口
`ResultAck.decision` 采用 CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL。
`reportAttempt` 回声本次回报,既有 `attempt` 为中心派发轮次;`resultState` 表示被采纳或首次保存的结果。
旧轮不推进新轮;只有显式 CENTER_TERMINAL 可以在 stale=true 时令 SH 镜像中心终态并停止重试。
缺少有效确认、身份/轮次不匹配、网络/不可路由保持重报,不得记成功。
完整状态矩阵以 [answer-delivery-v2 §11](answer-delivery-v2.md#11-r3r4-对账结果回执裁决2026-10-23-收口) 为准。
MQ publisher confirm/return 与 B-09 仍是独立投递状态机,本批没有修改。
有效租约中的 SH RUNNING 行保留执行 attempt 与 work_version;过期或终态再接纳重派。
行锁领取、领取时二次到期判定、work_version 过期执行者保护共同防止旧执行清除新租约。
+117
View File
@@ -0,0 +1,117 @@
# 表单中心 · 消费应用授权与表单目录(v3,TASK_1)
对外契约的增补件,挂在《表单中心契约 v2》§9(对外读接口)之下。落地范围=**发现(目录)+ 授权(read/subscribe)+ 稳定引用(uuid+version)**,
不含部门选型、不含 Apply 输入换轨(TASK_2)、不含事件投递(TASK_3)。
依据:VERIFICATION V03/V04/V07;原骨架 T01 与 T05 的确切版本读取部分。
---
## §1 调用身份:只有一个来源
`/oapi/form/**` 的身份 = 平台 `AppAuthFilter` 验签通过后写在请求属性 `yh.app.auth` 上的 **appKey**。
- 三件套 `X-App-Key / X-Timestamp / X-Signature` 缺失 → 平台链 401(`enforce-patterns=[/oapi/*]`),进不到业务层;
- 属性拿到了但密钥不可用,或 `OpenApiAppClient.getAppByKey` 不能在UserBase确认同一appKey → `UNAUTHORIZED`;
trusted-apps只提供验签密钥,不充当注册事实;注册查询不可达也拒绝,不把降级放行写成可用性保证;
- 查询参数 `appCode` 与 `schId` **只是资源筛选与租户域**,不构成身份、不授予拥有权;
- `app_registry` 仍是快应用目录(四业务列、零角色),本批一行不加,也不因 slug 同名自动认领任何资源。
## §2 落型:资源拥有者与消费租户分开
| 表 | 键 | 语义 |
|---|---|---|
| `form_ownership` | `uk_ownership_form(form_uuid)` | 这张表单是哪个 appKey 的资产;`form_school_id` 存绑定时表单自己的 `sch_id`(0=平台级) |
| `form_consumer_grant` | `uk_grant(form_uuid, consumer_school_id, consumer_app_key)` | 允许"这个租户的这个应用"**读**和/或**订阅**这张表 |
- **默认拒绝**:`read_allowed` / `subscribe_allowed` 默认 0,没有隐式全集;
- **读与订阅分权**:owner 天然能读自己那张表的 schema/答卷,**订阅必须另开一行 grant**(能看 ≠ 能往外推);
- **租户维度不可省**:只按 appKey 授权会丢掉"读答卷仍以该校行集限定"这条。平台级表单(`form.sch_id=0`)对某校授予后,
那一校看到的答卷仍只有自己的行(行集由调用侧 `schId` 条件保证,授权不放宽它);
- **owner 由管理面写**:校管理员/超管在写入前核验 appKey 的 UserBase 注册身份(§1 同一张注册表,只判存在、不回显密钥),
平台级表单的 owner 绑定**仅超管**可设——否则校管理员可以把平台资源认领给自己应用;
- **统一拒绝口径**:跨校 / 缺归属 / 不存在 / 未授权 → 同一句 `表单不存在:<uuid>`,真实原因只进服务端 warn 日志;
`answer` 端点被拒时回答 `答卷不存在:<answerId>`(答"表单不存在"等于同时确认两张对象都存在);
- **撤销不删行**:`enabled=0` + `grant_version` 进位;重申同样是新纪元(版本再进位)。
DDL:`sql/form_consumer_access_v3.sql`(无物理外键,`form_uuid` 为逻辑引用;两表零存量、无需回填)。
## §3 目录端点与既有端点的授权
```
GET /oapi/form/forms?schId=&appCode=&keyword=&page=&pageSize=
→ data = { items:[{formUuid, code, name, appCode, version, audience[]}], total }
```
- 过滤链:`sch_id ∈ (schId, 0)` ∩ `is_deleted=0` ∩ `status=已发布` ∩ **调用方可读**(owner ∪ 显式 read grant),
**先过滤再分页**——`total` 是过滤后的条数,页数不随授权抖动;
- `page` 从 1 起算(缺省 1),`pageSize` 缺省 20、**上限 100**(超限直接拒,不静默收窄);
- `keyword` 对 `name`/`code` 做大小写不敏感的包含匹配,`appCode` 精确匹配(仅筛选);
- `version`=当前发布指针,目录只出已发布行所以必非空。消费方选中后**成对保存** `formUuid + version`;
- 不做本校对平台级的遮蔽:目录按 uuid 寻址,两张同 code 的表是两个都合法的可选项(遮蔽规则属于按 code 的 resolve 入口)。
既有三个读端点同一批过 `requireRead`(签名通过不再等于全开):
| 端点 | 判定时机 |
|---|---|
| `GET /oapi/form/schema?uuid=[&version=]` | 解析**之前**(手里有 uuid,被拒与不存在同句) |
| `GET /oapi/form/schema?code=[&appCode=]` | 解析之后、返回载荷之前(code 形态先要拿到 uuid) |
| `GET /oapi/form/answer?answerId=` | 定位答卷之后,拒绝时回答"答卷不存在" |
| `GET /oapi/form/answers?form_uuid=` | 游标解析之前 |
CAS 自用填报路径不受影响:服务端应用 ACL 不替代填报入口的会话身份与行级数据范围(契约 v2 §8 那套仍在会话身份上)。
## §4 消费方(svc-hall / flow-center)保存什么
- 两端各出一个**同源目录代理**(管理面调用,浏览器不持 appKey/secret):校域由 CAS 会话派生,不由前端传;
- 只保存**服务器验证过的** `formUuid` + `formVersion`:不信自由输入的 `appCode`,也不信前端送来的版本号;
- flow-center 发布期调 `schema?uuid=&version=`,**返回坐标与请求不一致即阻断发布**;已发布快照不改写,改关联走新版本;
- svc-hall 事项保存时远端校验失败即整次保存失败(不写半个绑定)。
## §5 与旧默认开放行为的差异(上线必读)
改造前:`/oapi/form/**` 只要**应用签名通过**,对声明域(`schId` 本校 ∪ 平台级)内的 schema/answer/answers **默认全开**,
没有表单级 ACL,也没有目录端点。改造后:**没有任何 owner 绑定或 grant 的表单,对外一律不可读**——
包括此前"签名就能读"的存量表单。
⇒ **部署前必须逐张补 `form_ownership`(中心自有表)或 `form_consumer_grant`(跨应用消费)**,否则现有消费链路会读不到表。
这是本批刻意选择的 fail-closed 取向(默认拒绝优先于兼容),不做"未配置=放行"的过渡开关。
**已知会在升级当场受影响的一条现成链路**:flow-center 的流程定义**发布期**快照(`FormCenterSchemaClient`,
C1 第一刀)现在就在校域内按 uuid 读 `/oapi/form/schema`。中心升级后若没有给 flow-center 的 appKey(联调态 100019)
补 owner 或 read grant,**发布会被拒**(表现为"快照拉取失败")。上线顺序因此固定为:
① 中心侧先建好绑定/授权(按消费应用逐个授 read,flow-center 与 svc-hall 各一条起)→ ② 中心发布 → ③ 消费方发布。
svc-hall 侧本次是新增链路(改造前不读中心),所以只有"选不到表"这一种表现,不影响存量事项。
其余对外可见差异:
1. 新增 `GET /oapi/form/forms`(纯增量,不改变任何既有响应形状);
2. `schema`/`answer`/`answers` 的**成功响应逐字节不变**,只是多了一层拒绝;
3. 拒绝文案统一到"表单不存在/答卷不存在",原先"未发布/停用"这类差别信息只在**通过读权判定之后**才会给出。
## §6 管理面端点(守卫=校管理员 ∪ 超管,与 app 注册同一把)
```
GET /api/form/admin/form-access/owner?formUuid=&schId= → {formUuid, formSchoolId, ownerAppKey} | null
PUT /api/form/admin/form-access/owner?formUuid=&schId= body {ownerAppKey}
GET /api/form/admin/form-access/grants?formUuid=&schId= → [{formUuid, consumerSchoolId, consumerAppKey,
readAllowed, subscribeAllowed, enabled, grantVersion}]
POST /api/form/admin/form-access/grants?schId= body {formUuid, consumerSchoolId, consumerAppKey,
readAllowed, subscribeAllowed}
POST /api/form/admin/form-access/grants/revoke?schId= body {formUuid, consumerSchoolId, consumerAppKey}
```
- 校级管理员的管理域=本校:`consumerSchoolId` 不等于会话域即拒;超管可代某校授权/换绑;
- `consumerSchoolId` 必须 ≥1(0 是资源域不是租户);`readAllowed`/`subscribeAllowed` 至少开一把;
- appKey 写入前必须能在平台密钥注册表查到(否则"绑一个不存在的身份"会把资源挂到空气上),**响应不回显任何密钥**。
## §7 后续任务的唯一入口(不许另起一套 ACL)
`FormConsumerAccessService` 是授权判定的唯一出口:
- `ConsumerPrincipal resolveSignedCaller(String appKey)`(`ConsumerPrincipalResolver`)
- `void requireRead(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
- `SubscriptionGrant requireSubscribe(ConsumerPrincipal caller, Long consumerSchoolId, String formUuid)`
- `List<SubscriptionGrant> listEnabledSubscriptions(String formUuid, Long answerSchoolId)`
TASK_2(Apply 输入换轨)与 TASK_3(事件投递)只调这四个接口;`SubscriptionGrant.grantVersion` 必须随投递下发,
消费方据此识别"这份授权已作废"。**撤权使后续目录/读取/发布检查失败,但已经生成的任务不被撤权操作改写。**
+46
View File
@@ -0,0 +1,46 @@
# MQ 独立修复契约(2026-10-23,本地候选)
主链已经部署并由用户验收。本补丁只处理 MQ,不改变 UserBase 身份模型、HTTP 收据主链、FL、B5 或对账调度。
代码基线为 FC 核心 SHA 1717f4c47e26918e7f582d0c00ddd23b732ab98a;旧 MQ WIP 保留在原 worktree。
## 身份与路由
- `application_info.id` 是业务应用主身份;`appKey/appSecret` 是签名凭据;`appCode` 只用于部署/寻址以及现有注册查询。
- 兼容现有 v2 协议:`sourceAppKey/consumerAppKey` 仍携带凭据键,不改名、不把业务 id 填进去、不迁移已有业务绑定。
- source 默认引用最终 `yh.app-auth.app-key`;显式 source 与签名键不一致时拒绝。真正发布前还按本应用的部署 code 调已有 `getAppByCode`,核验注册行的正整数 id、appCode、appKey。允许 id != appKey。不可达/错配进入有界失败退避,不记 broker 成功。
- 不使用答卷的资源 `snapshot.appCode` 推断发布者身份。该字段是资源所属应用技术标识,原样保留。
- v2 routing key 固定为 `fc.snapshot.v2.<consumer credential key>.<schoolId>`,不能把应用主键或 appCode 代入。拒绝能扩展 topic 段的通配符/额外分隔符与非正学校号。
- SH 的 binding、信封消费身份与最终签名凭据必须同源;不硬编码为 100008 或 100018。
## 投递状态裁决
| 输入 | 条件 | 结果 |
|---|---|---|
| ACK | 当前代且没有已返回事实 | BROKER_ACCEPTED;不写 consumer_applied |
| 重复 ACK | 当前代已接受 | 时间戳/计数不再改写 |
| NACK / mandatory return | 当前代,包括先前已收到 ACK | FAILED 或预算耗尽 DEAD;清 broker_accepted_at,保留原因并退避 |
| ACK 带 CorrelationData.returned | 独立 return 回调未持久化也一样 | 先按 NO_ROUTE 失败处理,不能写成功 |
| 重复负回调/迟到旧代 ACK | 负回调已推进 retry_count 或授权重放操作已变化 | 幂等忽略,不清错误、不重复扣重试预算 |
| DEAD/GRANT_REVOKED | 任意 broker 回调 | 不复活 |
| 序列化/快照损坏/注册不可达 | 未实际发送 | 失败退避;同 eventId 与不可变快照恢复,不伪造消费成功 |
回调以现有 retry_count 与重放操作键摘要标识代际,读取同一投递行时使用写锁;没有新增库列。
这是至少一次投递,不能声称 broker/数据库的真实并发恰好一次已验证。
发送通道必须配置 correlated confirm、publisher-returns 和 mandatory;缺少返回事实观察能力时不启用扫描发送。
`push_allowed=0` 保留 read/subscribe 数据权,既不建新 v2 outbox 行,也不继续发失去 push 权的旧行。
## N-1 元数据事件
`fc.events.v1` 现在显式序列化为 UTF-8 JSON Message,再调用 RabbitTemplate.send;不更换共享模板的转换器,不影响 v2。
信封仍为十字段闭集;不含 values/附件字节/凭据。messageId 与 event_id 一致,提交后使用冻结的坐标发送。
`fc.events.enabled` 默认 false,且仍须 broker host;仅平台注入 host 不会打开已修好的事件通道。
该元数据通知与 v2 push 授权是两个协议;没有引入新的订阅授权模型。
保持原 best-effort 语义:转换/声明/发送失败不反向破坏答卷提交,不增加 v1 outbox 或自动重放;后续事件可以恢复。v2 才有有界可靠重试台账。
## B-03 及验证边界
B-03 原定义是共享 `/` vhost 与同一 admin 账号,不是转换器问题。独立 vhost、分权账号与 topic 前缀约束仍需环境交付。
FC 会主动 declare 自己的 exchange;SH RabbitAdmin 会声明队列/exchange/DLX/binding。因此权限方案须包含这些实际操作,不能只写“发布方 write”就认为配置完毕。
普通资源权限与 topic binding key 权限须分开核验;依据 [RabbitMQ 官方访问控制表](https://www.rabbitmq.com/docs/access-control),不是本轮 broker 实测结论。
本轮仅本地规则、真实 JSON 转换、Spring 条件装配测试。真实路由、NO_ROUTE、ACL、HMAC身份、DB并发与生产验收均 NOT RUN。
不得开启生产 MQ、修改 push_allowed 或 B-08。请使用独立测试 vhost 完成后续环境验证。
+145
View File
@@ -0,0 +1,145 @@
# 公共 schema 扩展契约草案(TASK_4)
日期:2026-10-22。状态:**文档检查完成,候选功能未实施**。本文不批准SUR-T19/SUR-T20完整功能,不取消OPEN-T19b代提交;不修改校验器白名单、题型注册、统计或流程引擎。普通表单、匿名、自填与原有代填能力继续按现有代码解释。托管SH首批仅本人,是独立的既有边界。
## 1. 证据基线与当前实现
本次重新读取各子仓库HEAD及当前工作区,未创建提交:
| 项目 | HEAD |
|---|---|
| FC后端 | `b2437a20742bfbfe38a564134904f3e0d7d931a3` |
| FC前端 | `2e406a88b1d9e09b2ca54a8cf68a3726174f49c0` |
| FL后端 | `2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f` |
HEAD不包含Task1~3及本轮精修的未提交内容。下列公共校验常量/函数锚点从当前源码重新摘录;FL快照字段搬运也与`git show HEAD:...FormCenterSchemaClient.java`对照。改动过的管理/提交路径引用当前工作区,并在根目录`docs/tasks/evidence/refinement-20261022/`留源文件指纹,不能把工作区源码宣称为HEAD中已实现。
FC / `src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java` / `FormSchemaValidator#validateSchema`、`#validateField`:
```java
if (!root.isObject() || !root.path("groups").isArray()) {
throw fail(null, "表单结构必须是 {\"groups\":[…]}");
}
rejectUnknownProps(group, ALLOWED_GROUP_PROPS, "分组属性");
rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
```
这三句分属根对象、group循环、field方法,不是连续源码。根对象当前只检查对象及groups形状,**没有统一拒绝全部根级未知属性**;这不意味着未知规则会执行。group仅允许key/label/fields;field允许以下19项:
```text
key,label,type,required,pattern,min,max,maxLength,tip,placeholder,options,
rows,defaultValue,accept,maxSize,maxFiles,precision,unit,searchable
```
FC / 同文件 / `FormSchemaValidator#validateOptions`:inline选项对象仅label/value;字典引用仅source且匹配`dict:<code>`。字段valueSchema由读接口生成,不属于设计写入白名单。visible_when、matrix及任意脚本属性都不是已实现契约。
FC / 同文件 / `FormSchemaValidator#PHASE1_TYPES`:
```java
public static final Set<String> PHASE1_TYPES = new LinkedHashSet<>(Arrays.asList(
"text", "textarea", "number", "date", "select", "radio", "upload",
"checkbox", "multiselect", "phone", "image"));
```
address/user/users虽在14类冻结命名中,但未在一期放行。FC前端 / `src/types/formSchema.ts` / `PHASE1_TYPES`、`ALLOWED_FIELD_PROPS`及group/option/ref常量与之对应;`src/components/fields/index.ts` / `fieldWidget`、`widgetTypes`注册11类,app端组件仍有槽位限制,不能仅按目录存在就声明移动端完整可用。
FC / `src/main/java/com/yunhai/formcenter/common/FormValueValidator.java` / `FormValueValidator#validate`:
```java
rejectUnknownKeys(data, fields);
if (isEmptyValue(value)) {
if (isRequired(field)) {
throw fail(field, key, "为必填项,未填写");
}
continue;
}
validateValue(field, key, value, dictCandidates);
```
当前没有先执行visible_when的步骤。FC / `common/FormValueSchemaBuilder.java` / `withValueSchemas`在深拷贝上增加机读值形状,不改变版本schema原文;number输出number与边界,未生成precision对应multipleOf;多选输出array,不虚构uniqueItems;附件items闭集为id/fileName/key。机读片段不是完整的执行引擎替代物。
## 2. 扩展分类(未来候选,不是今天的兼容承诺)
| 类别 | 候选承载方式 | 未来识别策略 | 当前事实 |
|---|---|---|---|
| 不可执行display metadata | 独立版本化命名空间,如`extensions.display`,只放纯文本/枚举/布局提示 | 明确允许的读写链路原样保留;不参与资格、值校验、路由、hash派生规则;所有展示转义 | 当前field/group未知属性仍拒绝,设计器会按白名单序列化;没有通用保留保证 |
| 可见性、必填联动、复合值约束等安全规则 | 明确schema契约版本及required capabilities | 写入/发布/提交/编辑/导入遇到不支持的必需规则必须拒绝,不能静默忽略 | 没有扩展能力协商或visible_when执行 |
| 生成的机读描述 | valueSchema及未来投影说明 | 从已核验schema派生,不能作为客户端绕过后端校验的权威 | valueSchema当前仅在读取时生成 |
不使用eval、Function、JS脚本、任意表达式字符串或可调用URL。候选规则采用受限JSON AST,字段引用只能指向本表schema中的稳定key;运算符、类型、深度、节点数、引用数需白名单和预算。下列形状只是评审样例,**现有字段白名单不会接受它**:
```json
{"key":"detail","type":"text","required":true,
"visible_when":{"op":"eq","field":"kind","value":"other"}}
```
将显示元数据和安全规则混放、遇到未知规则当作true、根据浏览器传来的visible结果跳过后端校验,均不属于候选契约。
## 3. 可见性、必填与隐藏值
未来统一顺序:按答卷formUuid/version取schema → 校验规则版本/类型/依赖 → 依拓扑顺序重算可见性 → 处理隐藏值策略 → 仅对可见字段执行required及值约束 → 附件归属/索引/存储。循环引用、未知key、类型错误、预算超限必须失败,不能转成“隐藏”。前端计算只用于体验,后端重算才是依据。
| 可见性结果 | required | 输入 | 候选处理 |
|---|---|---|---|
| visible | true | 缺失/null/类型约定的空值 | 拒绝必填空值;0与false不能当空值 |
| visible | false | 空 | 允许约定空值;是否缺键规范化为null须写入版本协议 |
| visible | 任意 | 非空 | 执行该题类型及值域校验 |
| hidden | 任意 | 无值 | 跳过required,不误报漏填 |
| hidden | 任意 | 有值 | 必须先定稿下表A/B策略,当前不选择、不实施 |
| 规则无效/结果未知 | 任意 | 任意 | 拒绝,返回稳定规则错误,不当作hidden |
| 隐藏值候选 | A:拒绝携带 | B:服务端剥离 |
|---|---|---|
| 用户改上游答案使下游隐藏 | 需要客户端清理后重交;错误可诊断 | 服务端规范化后接收,须告知哪些字段被移除 |
| 审计 | 记录拒绝字段名及规则版本,不把敏感values写日志 | 记录剥离字段名及规则版本,不能伪装成用户主动清空 |
| 幂等/hash | 需明确同一请求的完整输入一致性 | 必须先规范化再生成存储hash,并区分原始请求指纹 |
| 本人/管理员编辑 | 不允许通过隐藏字段偷偷补值 | 不允许以管理员权限跳过规则而无单独协议 |
| 历史数据 | 不在读详情时破坏性重写旧答卷 | 同样不在读详情时自动剥离历史值;需要显式编辑/迁移审计 |
A/B是实施前的业务决策,不能在开发中默认选一种。条件依赖于另一个隐藏字段时,该字段是缺失、历史保留还是不适用,也须一并定稿;禁止靠真假值隐式强转决定。
## 4. 普通题型与版本来源基线
| 项目/类#方法 | 当前值/版本事实 | 对扩展的边界 |
|---|---|---|
| FC `common/FormValueValidator#validateNumber` | JSON number,支持min/max、precision;number空值用null,不能用空字符串代替 | scale/矩阵不能借number类型塞数组或对象 |
| 同类 `#validateSingleOption` | select/radio为候选value字符串,存value而非label | 条件比较需比较稳定value,label变化不改历史语义 |
| 同类 `#validateMultiOption` | checkbox/multiselect为value字符串数组;逐项查候选,当前无显式去重拒绝 | 数组不得被FL按标量比较;不宣称JSONSchema有uniqueItems |
| 同类 `#validateAttachments`,`AnswerService`/`AnswerAdminService`附件归属检查 | upload/image为[{id,fileName,key}],服务端另核归属、key和大小/数量;值中不存URL | 隐藏附件的剥离、引用释放、历史下载权限须成对设计 |
| FC `service/AnswerService#doSubmit` | 登录普通提交按code/appCode解析发布版本;匿名走resolvePublishedForAnon,实际版本由服务端解析,不接受浏览器指定版本 | 未来普通提交须明确页面解析到写入之间的升版策略;不把hosted的钉版保证推到全部通道 |
| 同方法hosted分支,前端`views/Fill.vue#loadForm/#handleSubmit` | contextToken确定formUuid+formVersion,CAS确定本人及学校;后端按上下文读取确切schema | 扩展也必须按token版本,不能按中心最新schema计算可见性 |
| FC `service/AnswerImportService#publishedFields/#importAnswers` | 字段取当前发布版,并逐行走AnswerService.submit;并非已冻结整次导入的schema快照;upload/image列属于UNSUPPORTED_TYPES,不支持导入附件 | 未来需把预检与实际导入钉在同一版本,并与自填/编辑共用规则;不得把当前导入声称覆盖附件 |
| FC `service/AnswerReadService#myDetail/#editBySubmitter` | 按answer.formUuid/version读schema、编辑校验 | 不因当前表单升级而重解释旧答卷可见性 |
| FC `service/AnswerAdminService#edit` | 按答卷原版本校验并重建索引 | 管理员编辑不能另写显隐判据 |
| FC `service/AnswerExportService#fieldsForVersion/#fieldsByVersion` | 列头按当前版字段,逐答卷版本取字段解释值,缓存按version;标签/字典经expandSchema解析 | 并非已按历史版本并集导出全部已删题;schema原文钉版不等于外部字典label永远冻结 |
| FC `service/AnswerStatsService#stats/#groupField/#currentSchema` | 总数来自权限范围查询;字段元数据来自当前发布schema,按索引分组;没有题目可见性分母 | 不可把现有总数当作“看见该题人数”;跨版本同key统计须显式分组/映射 |
表内路径均相对于form-center/service,前端另已标明。该表为源码基线,**本轮未执行上述题型的功能测试**。
## 5. 统计、导出和历史语义候选
未来分母至少分别表示:范围内答卷总数N、该题在其钉版schema存在的答卷E、规则计算可见的答卷V、有效作答A。缺失题/隐藏/可见未答/有效答案不能合并为一个null桶。V与A必须按每份答卷自己的schema版本和规则版本计算;不能把最新规则用于全部历史版本。
- 创建/匿名创建/本人编辑/管理员编辑/导入:同一规则服务与相同的空值、隐藏值策略;写后索引、outbox和hash基于实际存储值,不能只在前端隐藏。
- 统计:题目存在比例用E/N,作答率用A/V(V=0时明确无定义而非除零);多选按答卷计可选次数,不能把选项次数当人数。矩阵每行分母、整题分母分别标注,不跨版本暗合并。
- 导出:按答卷版本给出字段/行列标识及不适用标记;隐藏值是“不适用”还是历史保留受A/B策略与权限共同决定。不得在导出过程中顺手改写答卷。
- 编辑:按原版本重算可见性;如果获批升级答卷schema,必须独立迁移任务、有原/新版本及审计,不混入普通编辑。
- 状态修改/软删:已有revision/outbox流程继续使用;规则版本与可见性派生数据是否改变快照/hash需后续协议显式升级,不能原地变更fc.canon.v1。
## 6. 矩阵/量表候选
未冻结matrix/scale题型,也未实现渲染、存储、校验或统计。若获批,先定义稳定rowKey/columnValue及整题显隐;不能靠每个单元格的动态隐藏解释同一复合值。
```json
{"satisfaction":{"row_service":"good","row_speed":"average"}}
```
上例仅为object值的候选;也可评审显式array结构,但须择一版本化,不能同时接受多种模糊编码。unknown row/column、重复row、必答行缺失、额外键、空行与不适用行的行为均须有真值表。复合值到FL必须拒绝作为标量路由,或经获批、显式、版本化的投影变成单一键/数值/枚举。
## 7. 下一批实施与资源边界
下批应成对分配FC校验器/前端白名单/渲染/提交编辑导入/统计导出与FL快照兼容,文件与任务拆分见FL伴随文档。候选预算可先评审:规则深度8、节点64、引用32,矩阵行列各50、单题2500单元格;这些是待定资源上限,不是当前代码保证。超限必须静态拒绝,不执行递归无界计算。
实施前待定:A/B隐藏值策略、规则JSON版本、依赖隐藏字段语义、字典历史label、矩阵结构、投影命名/空值/值域及统计口径。未决项不影响本次只交付草案,但禁止据草案直接编码候选功能。
验收状态:文档边界/样例/真值表交叉检查完成;FormSchemaValidatorTest等题型测试未运行(遵循本轮用户仅编译/打包要求)。本次相关后端打包只证明编译及构建,不能证明visible_when/matrix支持。
+8
View File
@@ -64,6 +64,14 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> </dependency>
<!-- MQ 事件推送(契约 v2 §6.2,T17):**可选依赖**——RABBITMQ_HOST 未配置时 FcEventPublisher 整体
禁用、服务照跑(照上面 data-redis 那条先例:引依赖但不引运行期强耦合)。
版本由 BOM 管理(Boot 2.7.18 → amqp 2.4.x,Java 8 兼容)。
配套 yml:management.health.rabbit.enabled=false,否则无 broker 时 /actuator/health 直接 DOWN(V64)。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- CAS 客户端(UserHome/Cms/svc-hall/flow-center 同款显式声明) --> <!-- CAS 客户端(UserHome/Cms/svc-hall/flow-center 同款显式声明) -->
<dependency> <dependency>
<groupId>org.jasig.cas.client</groupId> <groupId>org.jasig.cas.client</groupId>
+105
View File
@@ -0,0 +1,105 @@
-- ============================================================
-- 表单中心 v2 · 快照投递与可信提交上下文 DDL(MySQL 5.7 兼容复核版,TASK_2)
-- 范围:《answer-delivery-v2.md》§2/§3/§6 = answer.answer_revision 加列 + 建两张表
-- answer_delivery(outbox)/ submission_context(可信提交上下文)
-- 依据:任务书 TASK_2 B.1(单调修订号与不可变 outbox)、B.3(逐授权订阅一行)、
-- A.2/A.3(令牌摘要与意图↔答卷一对一原子关联)
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由
-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v3/t16 先例)。
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写
-- CHARSET/COLLATE;③<b>不建物理外键</b>——answer_uuid / form_uuid 都是逻辑引用
-- (答卷无物理删除但表单可软删,投递行与上下文必须能在其之后继续存在以支撑溯源);
-- ④唯一键里的 answer_revision 是 BIGINT,不占 utf8mb4 长度预算。
-- 身份口径:consumer_app_key / source_app_key 存<b>平台 appKey</b>(UserBase 注册分配、
-- 验签三件套里的 X-App-Key),<b>不是</b> form.app_code 那种人读 slug——
-- 资源 appCode 当消费者会把"同应用多消费方"合并成一行(任务书 B.3 点名禁止)。
-- 双时刻纪律:broker_accepted_at 与 consumer_applied_at 是两列、两个写入方,
-- 前者只由 confirm 回调写、后者只由签名回执端点写;任何一侧都不许替另一侧作证(B.4/B.6)。
-- 存量:answer.answer_revision 默认 0,<b>不回填历史版本、不建双轨</b>(任务书 A.5 末句)——
-- 存量行此后第一次变更取号 1,此前的对外快照本来就没有 hash 事实,回填等于伪造历史。
-- 非幂等:MySQL 5.7 无评审友好的 ADD/CREATE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。
-- 执行前先 `SHOW TABLES LIKE 'answer_delivery'` / `'submission_context'` 确认不存在,
-- 并 `SHOW COLUMNS FROM answer LIKE 'answer_revision'` 确认列不存在。
-- ============================================================
ALTER TABLE `answer`
ADD COLUMN `answer_revision` BIGINT NOT NULL DEFAULT 0
COMMENT '对外快照修订号:单调递增,凡改变对外快照的路径(创建/值编辑/改状态/软删,含批量)在同一事务内 +1 并写 outbox;与钉版 version 是两个维度';
CREATE TABLE `answer_delivery` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`event_id` VARCHAR(36) NOT NULL COMMENT '信封事件 id(uuid):broker 相关键与消费端幂等键同一件',
`protocol_version` VARCHAR(32) NOT NULL COMMENT '固定 fc.answer.snapshot.v2(锁死 fc.canon.v1 哈希口径)',
`event_type` VARCHAR(16) NOT NULL COMMENT 'created / updated / tombstoned(值改与状态改同归 updated)',
`source_app_key` VARCHAR(64) DEFAULT NULL COMMENT '发布方应用身份(服务端配置,不接受消费方申报)',
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '投递目标:显式消费应用平台 appKey',
`consumer_school_id` BIGINT NOT NULL COMMENT '投递目标租户域(与 appKey 一起构成授权坐标,不是资源归属校)',
`grant_version` INT NOT NULL COMMENT '入队时刻的授权纪元;发布前再核一次,进位或撤销即不重放',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标(逻辑引用,不建 FK)',
`form_version` INT NOT NULL COMMENT '答卷钉住的 schema 版本(与 answer.version 同值)',
`answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid(对外身份,库内 id 不外露)',
`answer_revision` BIGINT NOT NULL COMMENT '本行对应的快照修订号(与 answer.answer_revision 同值)',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑标记;软删行的快照照常投递,靠它表达删除',
`content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 下核心快照(剔除 intent)的 SHA-256 十六进制小写',
`occurred_at` DATETIME NOT NULL COMMENT '业务发生时刻(= 答卷 update_time;投递时间不在此列)',
`snapshot_json` LONGTEXT NOT NULL COMMENT '不可变载荷:该消费方看到的完整快照(含仅对其可见的 intent 关联块)',
`state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING / BROKER_ACCEPTED / FAILED / DEAD / GRANT_REVOKED',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '投递元数据:重试计数<b>不进快照、不参与哈希</b>',
`next_retry_at` DATETIME DEFAULT NULL COMMENT '退避到点才再扫(base×2^n 封顶 1h),DEAD 置 NULL 不再自动重投',
`broker_accepted_at` DATETIME DEFAULT NULL COMMENT '总线 confirm(ack=true) 时刻——只证明总线收下,不证明业务应用',
`consumer_applied_at` DATETIME DEFAULT NULL COMMENT '消费方签名回执落定时刻(只有回执端点写得了)',
`consumer_outcome` VARCHAR(16) DEFAULT NULL COMMENT 'APPLIED / IGNORED / CONFLICT;重复上报不同结论时保留首次',
`last_replay_operation_id` VARCHAR(64) DEFAULT NULL COMMENT '最近一次授权重投的操作幂等键(同键不重复刷新退避)',
`last_error` VARCHAR(500) DEFAULT NULL COMMENT '末次失败原因或回执 localRef(截断存)',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_delivery_event` (`event_id`),
UNIQUE KEY `uk_delivery_revision` (`answer_uuid`, `answer_revision`, `consumer_app_key`, `consumer_school_id`),
KEY `idx_delivery_scan` (`state`, `next_retry_at`),
KEY `idx_delivery_consumer` (`consumer_app_key`, `consumer_school_id`, `id`),
KEY `idx_delivery_answer` (`answer_uuid`, `answer_revision`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='答卷快照 outbox(v2 §3:与答卷同事务落库、逐授权订阅一行、双时刻分列;不提供任何删除方法)';
CREATE TABLE `submission_context` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用平台 appKey(取自已验签的 yh.app.auth,非请求体申报)',
`consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(≥1;签发时即过 read+subscribe 两把授权)',
`intent_id` VARCHAR(64) NOT NULL COMMENT '消费方业务意图键(服务端生成;浏览器不可注入)',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '钉定的表单 uuid(成对校验过版本行才落)',
`form_version` INT NOT NULL COMMENT '钉定的确切版本号(hosted 永不回落到当前发布版)',
`app_code` VARCHAR(64) NOT NULL COMMENT '解析出来的资源 appCode(提交时按它寻址,不再走 code 遮蔽)',
`code` VARCHAR(64) NOT NULL COMMENT '解析出来的表单 code(与 app_code 一起构成提交体坐标核对)',
`submitter_id` INT NOT NULL COMMENT '消费方服务端断言的填报人;兑换时与 CAS 会话逐字复核',
`token_digest` CHAR(64) NOT NULL COMMENT 'contextToken 的 SHA-256(<b>明文不入库</b>,只在签发响应里出现一次)',
`embed_origin` VARCHAR(200) NOT NULL COMMENT '可嵌入父页面精确 origin(服务端配置锁定,被嵌页 postMessage 的唯一目标)',
`callback_url` VARCHAR(500) NOT NULL COMMENT '消费方确认入口绝对 URL(服务端配置;浏览器传入的一律不采信)',
`expires_at` DATETIME NOT NULL COMMENT '有效期(默认 30 分钟,form-center.hosted-fill.token-ttl-minutes)',
`answer_uuid` VARCHAR(32) DEFAULT NULL COMMENT '答卷原子关联(提交事务内写;NULL=尚未填报。一个意图只挂一份答卷)',
`linked_revision` BIGINT DEFAULT NULL COMMENT '关联时刻的快照修订号(收据与投影合并的判据之一)',
`linked_at` DATETIME DEFAULT NULL COMMENT '关联时刻',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ctx_token_digest` (`token_digest`),
UNIQUE KEY `uk_ctx_intent` (`consumer_app_key`, `intent_id`),
UNIQUE KEY `uk_ctx_answer` (`answer_uuid`),
KEY `idx_ctx_form` (`form_uuid`, `form_version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='可信提交上下文(v2 §6:意图↔答卷一对一、令牌只存摘要、坐标与 origin/回调都在签发时锁死)';
-- 自验(执行后逐条核对):
-- SHOW CREATE TABLE answer_delivery\G -- 应含 uk_delivery_event / uk_delivery_revision 两个唯一键
-- SHOW CREATE TABLE submission_context\G -- 应含 uk_ctx_intent(同意图不新建第二行)与 uk_ctx_answer
-- SHOW COLUMNS FROM answer LIKE 'answer_revision'; -- 必须存在且 NOT NULL DEFAULT 0
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema=DATABASE() AND table_name IN ('answer_delivery','submission_context')
-- AND column_name REGEXP 'secret|token$|retry_policy'; -- 必须 0 行(密钥与明文令牌都不入库)
-- SELECT COUNT(*) FROM information_schema.referential_constraints
-- WHERE table_name IN ('answer_delivery','submission_context'); -- 必须 0 行(无物理外键)
-- SELECT COUNT(*) FROM answer WHERE answer_revision <> 0; -- 刚执行完必须 0(不回填历史)
+128
View File
@@ -0,0 +1,128 @@
-- ============================================================
-- 表单中心 · 差异对账任务 DDL(MySQL 5.7 兼容复核版,TASK_3)
-- 范围:answer-reconcile-v1 §2/§3/§5 = 三张新表
-- answer_reconcile_task(任务)/ answer_reconcile_manifest_row(不可变清单)/
-- answer_reconcile_result(结果回报)
-- 依据:任务书 TASK_3 改动点 1/2/4/8;不复用 answer_op_log 当任务表、不动 answer/answer_delivery。
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由
-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v2/v3 先例)。
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
-- ③<b>不建物理外键</b>——task_uuid 是逻辑引用,清单与结果要能在任务行之后继续存在以支撑复盘;
-- ④清单游标与本地扫描游标同为 answer_uuid(32 位小写十六进制),general_ci 下与 binary 序一致。
-- 身份口径:consumer_app_key 存平台 appKey(验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug。
-- 审计纪律:三张表都<b>没有删除路径</b>(服务层与仓库层都不提供 delete),终态留表供复盘。
-- 非幂等:MySQL 5.7 无评审友好的 CREATE TABLE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。
-- 执行前先 `SHOW TABLES LIKE 'answer_reconcile%'` 确认三张表都不存在。
-- ============================================================
CREATE TABLE `answer_reconcile_task` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`task_uuid` VARCHAR(36) NOT NULL COMMENT '任务对外身份(服务端 UUID,≤36 字符;URL 与指令都用它)',
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '对账目标的消费应用平台 appKey(<b>不是</b> app_code slug)',
`consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域:与投递行同口径,也就是答卷归属校',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '本轮范围=一张表单;全量=按生效订阅 fission 成多行任务',
`form_version` INT NOT NULL COMMENT '物化时核验过的发布版本(缺席答复与修复信封的 formVersion 权威值)',
`trigger_type` VARCHAR(16) NOT NULL COMMENT 'SCHEDULE / MANUAL(列名避开 MySQL 保留字 TRIGGER)',
`operator` VARCHAR(64) DEFAULT NULL COMMENT '人工触发时的操作人(调度产生的为 NULL)',
`idempotency_key` VARCHAR(128) NOT NULL COMMENT '日期+订阅+范围唯一键:调度重复跑不出第二行任务',
`state` VARCHAR(16) NOT NULL DEFAULT 'QUEUED' COMMENT 'QUEUED/DISPATCHED/RUNNING/COMPLETED/PARTIAL/FAILED/CANCELLED',
`manifest_state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/MATERIALIZING/COMPLETE/FAILED:派发只在 COMPLETE 后',
`manifest_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '清单结束标记:<b>只有它为 1</b>,"清单里没有"才可能是权威缺席',
`total_rows` INT NOT NULL DEFAULT 0 COMMENT '物化完成后=清单行数(上限见 fc.answer.reconcile.max-rows)',
`total_pages` INT NOT NULL DEFAULT 0 COMMENT '确定总页(ceil(total_rows/page_size),不是"某页空了")',
`materialized_rows` INT NOT NULL DEFAULT 0 COMMENT '已写入清单行数(进度与断点续作的计数)',
`materialized_pages` INT NOT NULL DEFAULT 0 COMMENT '已物化页数',
`checkpoint_cursor` VARCHAR(32) DEFAULT NULL COMMENT '物化检查点:已写到的最后一个 answer_uuid(重启从这里续)',
`window_start` DATETIME NOT NULL COMMENT '任务起点=一致读事务开始时刻;晚于此刻的本地行不得判为多余行',
`window_end` DATETIME DEFAULT NULL COMMENT '清单物化完成时刻(窗口长度给运维复盘用)',
`grant_version` INT NOT NULL DEFAULT 1 COMMENT '物化时刻的授权纪元;每轮读取再核一次,进位即按撤权处置',
`attempt` INT NOT NULL DEFAULT 0 COMMENT '派发尝试次数(首派 0;租约超期或派发失败后 +1,有上限)',
`lease_until` DATETIME DEFAULT NULL COMMENT '派发/物化租约到期时刻:超期由扫描按 attempt+1 有限退避接手',
`next_dispatch_at` DATETIME DEFAULT NULL COMMENT '退避到点才再派(base×2^n 封顶),耗尽即 FAILED',
`dispatched_at` DATETIME DEFAULT NULL COMMENT '对端 2xx 收下指令的时刻(<b>不代表执行完成</b>,终态只看 result 回报)',
`finished_at` DATETIME DEFAULT NULL COMMENT '终态落定时刻',
`cancel_requested` TINYINT NOT NULL DEFAULT 0 COMMENT '取消请求:置 1 后清单/快照读取一律以取消口径答复,消费端据此停止剩余修复',
`error_code` VARCHAR(32) DEFAULT NULL COMMENT '稳定错误码(GRANT_REVOKED/RESOURCE_LIMIT/MANIFEST_INCOMPLETE/…)',
`error_detail` VARCHAR(500) DEFAULT NULL COMMENT '末次错误摘要(截断存;答卷值文档绝不进这里)',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_reconcile_task_uuid` (`task_uuid`),
UNIQUE KEY `uk_reconcile_task_idem` (`idempotency_key`),
KEY `idx_reconcile_dispatch` (`state`, `next_dispatch_at`),
KEY `idx_reconcile_consumer` (`consumer_app_key`, `consumer_school_id`, `id`),
KEY `idx_reconcile_coord` (`consumer_app_key`, `consumer_school_id`, `form_uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='差异对账任务(TASK_3 §2:一行=一个 (消费应用, 消费租户域, 表单) 坐标上的一次对账企图;不是投递行、不是答卷、不是 op_log)';
CREATE TABLE `answer_reconcile_manifest_row` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '逻辑引用 answer_reconcile_task.id(不建物理外键)',
`task_uuid` VARCHAR(36) NOT NULL COMMENT '任务身份冗余一份:清单行脱离任务上下文也要能自证是哪一轮',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '物化时核验过的表单坐标',
`form_version` INT NOT NULL COMMENT '该答卷钉住的 schema 版本(= answer.version,不从旧清单猜版本)',
`answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid:清单游标与消费端本地游标同为它',
`revision` BIGINT NOT NULL COMMENT '物化时刻的对外修订号(含 0:存量未变更行原样入清单,修复端点会拒绝并只报告)',
`content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 摘要(与投递行同一条规范化实现,本表不复制算法)',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑也进清单:"缺席"与"存在但已删"是两个不同结论',
`seq_no` INT NOT NULL COMMENT '物化写入序号(审计与断点续作的序号锚;分页游标不是它)',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_manifest_row` (`task_id`, `answer_uuid`),
KEY `idx_manifest_page` (`task_id`, `answer_uuid`),
KEY `idx_manifest_seq` (`task_id`, `seq_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='任务不可变清单(TASK_3 §3:一致读事务内物化四元摘要含墓碑;此后分页只读它,全列 updatable=false,无 update/delete 方法)';
CREATE TABLE `answer_reconcile_result` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '逻辑引用 answer_reconcile_task.id',
`task_uuid` VARCHAR(36) NOT NULL COMMENT '任务身份(回报定位用)',
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '回报来源:<b>必须等于已验签身份</b>,自报不符即拒',
`consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(跨校回报的判据之一)',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '范围坐标(每条错误与计数都能定位到这里)',
`attempt` INT NOT NULL COMMENT '对应的派发尝试;比最新 attempt 更旧的回报不推进状态(不倒退新 attempt)',
`state` VARCHAR(16) NOT NULL COMMENT 'COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报)',
`local_scan_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '消费端本地扫描是否整轮完成(多余行判定的前提之一)',
`manifest_complete` TINYINT NOT NULL DEFAULT 0 COMMENT '消费端实际读到的中心结束标记(与中心不一致则本轮结论不成立)',
`scanned_rows` INT NOT NULL DEFAULT 0 COMMENT '比较过的答卷数',
`pages_processed` INT NOT NULL DEFAULT 0 COMMENT '处理页数',
`missing_repaired` INT NOT NULL DEFAULT 0 COMMENT '缺失修复数(repairAuthoritative=APPLIED,期望三元组全 null)',
`stale_repaired` INT NOT NULL DEFAULT 0 COMMENT '陈旧修复数(本地 revision 更低)',
`hash_conflicts` INT NOT NULL DEFAULT 0 COMMENT '同 revision 异 hash:记录后按中心当前值修复',
`tombstones_repaired` INT NOT NULL DEFAULT 0 COMMENT '墓碑修复数(同一权威修复原语,保留本地业务扩展)',
`local_ahead` INT NOT NULL DEFAULT 0 COMMENT '本地 revision 更高:只向中心复核,禁止降级覆盖',
`deferred` INT NOT NULL DEFAULT 0 COMMENT '授权核验不可达/撤权/纪元不等而暂停的条目',
`extra_reported` INT NOT NULL DEFAULT 0 COMMENT '只报告的多余行(含期间新增、createTime 未知、范围不明)',
`extra_retired` INT NOT NULL DEFAULT 0 COMMENT '经 FC 签名缺席证明实际退役的行数(无证明即不增)',
`error_count` INT NOT NULL DEFAULT 0 COMMENT '错误条目总数(超上限会截断,明细以 errors_json 为准)',
`errors_json` MEDIUMTEXT NOT NULL COMMENT '错误明细(有上限、截断存;每条含 consumer/task/range/answer 四坐标,<b>不含 values</b>)',
`resumed` TINYINT NOT NULL DEFAULT 0 COMMENT '1=进程重启后从检查点续跑的同一 attempt 再报',
`reported_at` DATETIME NOT NULL COMMENT '中心受理该回报的时刻',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_result_attempt` (`task_id`, `attempt`),
KEY `idx_result_task` (`task_id`, `attempt`),
KEY `idx_result_consumer` (`consumer_app_key`, `consumer_school_id`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='对账结果回报(TASK_3 §5:(task_id, attempt) 唯一=幂等锚点;一 attempt 一行不覆盖,APPLIED 类计数不代表建件或流程成功)';
-- 自验(执行后逐条核对):
-- SHOW CREATE TABLE answer_reconcile_task\G -- 应含 uk_reconcile_task_idem(同日同订阅同范围只一行)
-- SHOW CREATE TABLE answer_reconcile_manifest_row\G -- 应含 uk_manifest_row(同任务同答卷不重复入清单)
-- SHOW CREATE TABLE answer_reconcile_result\G -- 应含 uk_result_attempt(重复回报幂等吸收的库级形态)
-- SELECT COUNT(*) FROM information_schema.referential_constraints
-- WHERE table_name IN ('answer_reconcile_task','answer_reconcile_manifest_row','answer_reconcile_result');
-- -- 必须 0 行(无物理外键)
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema=DATABASE() AND table_name='answer_reconcile_result'
-- AND column_name IN ('values_json','snapshot_json','secret','token');
-- -- 必须 0 行(值文档与密钥不进对账留痕)
+64
View File
@@ -0,0 +1,64 @@
-- ============================================================
-- 表单中心 v3 · 消费应用授权 DDL(MySQL 5.7 兼容复核版,TASK_1)
-- 范围:《consumer-access-v3.md》§2/§3 = 建两张表 form_ownership / form_consumer_grant
-- 依据:VERIFICATION V03/V04/V07;owner 绑定与 grant 分权(读/订阅)、默认拒绝、撤销留痕
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate
-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3/t10/t16 先例)。
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
-- ③<b>不建物理外键</b>——form_uuid 对 form 是逻辑引用(同 t16 对 app_code 的处理:
-- 表单软删是唯一切除形态且无物理删除,绑定/授权行必须能在表单软删后继续存在以支撑溯源,
-- 建 FK 等于把"级联删"写进库侧规则);④无可空业务列(判权路径不写"未知=怎样"的分支)。
-- 身份口径:owner_app_key / consumer_app_key 存的是<b>平台 appKey</b>(UserBase 应用注册分配,
-- 验签三件套里那个 X-App-Key),<b>不是</b> form.app_code 那种人读 slug。
-- app_registry 仍是快应用目录,本批一行不加(v3 §1 红线:不把业务授权塞回注册表)。
-- 租户口径:consumer_school_id 既是授权生效域,也是读答卷时的行集限定域;0 不是合法消费域
-- (平台级是资源域,不是一个能答卷的租户),服务侧直接拒。
-- 默认拒绝:两把 *_allowed 开关默认 0,enabled 默认 1(建行即生效,但建之前服务侧必须显式给开关);
-- 撤销是 enabled=0 + grant_version 进位,<b>不删行</b>(判权 SQL 必须带 enabled 条件)。
-- 非幂等:MySQL 5.7 无 ADD/CREATE IF NOT EXISTS 的评审友好写法(写了反而掩盖"表已存在但列不对"的漂移)。
-- 执行前先 `SHOW TABLES LIKE 'form_ownership'` / `'form_consumer_grant'` 确认不存在;
-- 存量:系统未上线,两张表零存量、无需回填(与 t16 同一论证)。
-- ============================================================
CREATE TABLE `form_ownership` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标:表单全局 uuid(契约 §1,不可枚举、跨版本不变)',
`form_school_id` BIGINT NOT NULL COMMENT '绑定时表单自己的 sch_id;0=平台级表单',
`owner_app_key` VARCHAR(64) NOT NULL COMMENT '资源拥有者应用的平台 appKey(写入前已核验 UserBase 注册身份)',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ownership_form` (`form_uuid`),
KEY `idx_ownership_owner` (`owner_app_key`, `form_school_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='表单资源拥有者绑定(v3 §2:一张表一个 owner,只能由管理面写;form_uuid 为逻辑引用不建 FK)';
CREATE TABLE `form_consumer_grant` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`form_uuid` VARCHAR(32) NOT NULL COMMENT '被授权的表单 uuid',
`consumer_school_id` BIGINT NOT NULL COMMENT '消费租户(授权生效域 = 读答卷的行集限定域,≥1)',
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用的平台 appKey',
`read_allowed` TINYINT NOT NULL DEFAULT 0 COMMENT '读授权:目录/schema/answer/answers;默认拒绝',
`subscribe_allowed` TINYINT NOT NULL DEFAULT 0 COMMENT '订阅授权:答卷变更事件投递(owner 不天然持有)',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '生效开关;0=已撤销(保留行供审计与 grant_version 判新旧)',
`grant_version` INT NOT NULL DEFAULT 1 COMMENT '授权版本号:撤销与重申都进位,投递侧必须带它下发',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_grant` (`form_uuid`, `consumer_school_id`, `consumer_app_key`),
KEY `idx_grant_consumer` (`consumer_app_key`, `consumer_school_id`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='消费应用对某张表单的显式授权(v3 §2:三元唯一、读与订阅分权、默认拒绝、撤销不删行)';
-- 自验(执行后逐条核对):
-- SHOW CREATE TABLE form_ownership\G -- 应含 UNIQUE KEY `uk_ownership_form` (`form_uuid`)
-- SHOW CREATE TABLE form_consumer_grant\G -- 应含 UNIQUE KEY `uk_grant` (三元键) 与 idx_grant_consumer
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema=DATABASE() AND table_name IN ('form_ownership','form_consumer_grant')
-- AND column_name REGEXP 'secret|app_code|role|perm'; -- 必须 0 行(密钥与 app_code 都不进授权表)
-- SELECT COUNT(*) FROM information_schema.referential_constraints
-- WHERE table_name IN ('form_ownership','form_consumer_grant'); -- 必须 0 行(无物理外键)
+67 -45
View File
@@ -1,9 +1,28 @@
-- ============================================================================ -- ============================================================================
-- ⛔ 契约 v2 终态(T14,2026-10-21 用户裁决落地):**本脚本整份作废,不再在任何环境执行**。
-- 依据=《表单中心契约v2-权限简化与开放接入.md》§2.4(D5:UserBase 模块行整条退役)
-- 与 §19 作废-10:中心五行 customId 200~204、300 段快应用行、组授权全部不再登记;
-- 后端 `GetUserMod` 端点与 `ModuleTreeSource` 已删除,左导航改由三支路自解析
-- (`/api/form/my/fillable`、`/my/managed`、`/my/capabilities`)。
-- H2 登记事项永久关闭(含"超管加 /f/honor_student 模块行"这条遗留项)。
-- 下方原文**按用户要求整份保留供追溯**(不作删除),其中的"执行/复核"步骤一律不再执行;
-- UserBase 侧存量行(200~204 与 300 段)的摘除属 P-C 确认后的用户侧动作,见《执行手册》§5 与本批报告。
-- ============================================================================
--
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本 -- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
-- 编制:Qoder(T9 联调轮)|日期:2026-10-15 -- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役)
-- 依据:《表单中心执行手册》§2.2(中心应用六条模块行表 + 验证口径)、 -- 改版:**P95(2026-10-16)快应用段 5→4 行——`/f/{code}` 填报行及其组授权整条撤销**
-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.4(customId=fc:scope:all)、 -- 终态口径(任务书 P95 §0.1 三层角色模型,用户 2026-10-16 裁决):填报人群由表单中心按
-- §6.5(中心=BUSINESS 应用,模块行与快应用模块行两棵树不混) -- `audience × 会话身份 × 管理员两表` 自解析(后端 FillAccessService + GET /api/form/my/fillable),
-- UserBase 侧**不再逐表单配填报模块行**——发一张表单求一次超管的成本就此归零。
-- 快应用树只剩四种行:数据查看 / 统计 两个页面行 + 导入 / 导出 两个 path 空按钮锚点。
-- **中心应用五行(200~204)与 /admin/* 四行原样不动**(P95 红线 2:管理端入口仍只认模块行)。
-- ⚠ 存量环境里已录入的 `/f/` 行不在本脚本删除范围内(脚本只做"不存在才插",从不删行)——
-- 清理清单见 docs/P95-report.md 的「存量清理」段,由超管在管理端手点删。
-- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、
-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8;P95 注记后收敛为两步)、§6.5(中心=BUSINESS 应用,
-- 模块行与快应用模块行两棵树不混)
-- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役)
-- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法) -- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法)
-- --
-- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。 -- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。
@@ -37,14 +56,17 @@ SET @operator = 0; -- create_user 占位(0=系统)
-- WHERE @center_app_id = 0; -- WHERE @center_app_id = 0;
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- 步骤 1:中心应用六条模块行(执行手册 §2.2 表;path 必须逐字等于 front/src/router/routes.ts) -- 步骤 1:中心应用五条模块行(执行手册 §2.2 v1.5 表;path 必须逐字等于 front/src/router/routes.ts)
-- ①表单设计 /admin/forms ②数据管理 /admin/data ③统计 /admin/stats -- ①表单设计 /admin/forms ②数据管理 /admin/data ③统计 /admin/stats
-- ④字典管理 /admin/dicts ⑤我的填写 /my(全员) ⑥数据全量【path 留空 + customId=fc:scope:all】 -- ④应用设置 /admin/app(页内 tab:角色设置/字典管理,字典 API 摊这一行把关) ⑤我的填写 /my(全员)
-- 第⑥行=纯权限锚点:导航不渲染、后端 DataScopeService 只扫 path 空节点的 customId(P47 收紧后口径), -- v1.5 改版(T10,2026-10-15 用户裁决):
-- 授权给哪个组,该组的数据范围即升级为"全部"(方案 §6.4)。 -- · 原「字典管理 /admin/dicts」独立行撤销,迁入应用设置页内 tab(不占 modId、不进导航);
-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail) -- · 原「数据全量」path 空锚点行撤销——UserBase 侧 customId 数据范围协议退役,
-- 升级载体改为中心自建的管理员两表(school_admin / form_admin,见《管理员体系方案》§4/§5.1)。
-- **中心应用自此不再有 path 空行**;前端 menuNav 的"path 空跳过"规则保留给快应用面的按钮锚点。
-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail、/admin/app 的页内 tab)
-- **不建模块行**(手册 §2.2 注记 + P63/P77):不占 modId、不进导航,前端按 meta.parentPath 归位。 -- **不建模块行**(手册 §2.2 注记 + P63/P77):不占 modId、不进导航,前端按 meta.parentPath 归位。
-- custom_id 用 200~205 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId) -- custom_id 用 200~204 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId)
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
INSERT INTO app_module INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) (app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
@@ -53,16 +75,14 @@ SELECT * FROM (
'/admin/forms' AS path, 'word' AS icon, 1 AS level, 200 AS sort_no, @operator AS create_user, NOW() AS create_time '/admin/forms' AS path, 'word' AS icon, 1 AS level, 200 AS sort_no, @operator AS create_user, NOW() AS create_time
UNION ALL SELECT @center_app_id, NULL, '数据管理', '201', '/admin/data', 'category', 1, 201, @operator, NOW() UNION ALL SELECT @center_app_id, NULL, '数据管理', '201', '/admin/data', 'category', 1, 201, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '统计', '202', '/admin/stats','tongji', 1, 202, @operator, NOW() UNION ALL SELECT @center_app_id, NULL, '统计', '202', '/admin/stats','tongji', 1, 202, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '字典管理', '203', '/admin/dicts','zidingyi', 1, 203, @operator, NOW() UNION ALL SELECT @center_app_id, NULL, '应用设置', '203', '/admin/app', 'zidingyi', 1, 203, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '我的填写', '204', '/my', '', 1, 204, @operator, NOW() UNION ALL SELECT @center_app_id, NULL, '我的填写', '204', '/my', '', 1, 204, @operator, NOW()
-- 锚点行:path 空 + customId 协议值(非数字 customId 是刻意的,前端 modId 会回退主键 id,不影响消费)
UNION ALL SELECT @center_app_id, NULL, '数据全量', 'fc:scope:all', '', '', 1, 205, @operator, NOW()
) t ) t
WHERE @center_app_id > 0 WHERE @center_app_id > 0
AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id); AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id);
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8 四步的前两步) -- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8;**P95 注记后 R8 只剩①建应用行 + ④学校开通**)
-- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改 -- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改
-- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。 -- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
@@ -83,17 +103,17 @@ SET @quick_app_id = 0; -- ← 按上一步结果填;0=还没建
-- WHERE @quick_app_id = 0; -- WHERE @quick_app_id = 0;
-- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填 -- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填
-- 模块行五条(方案 §6.2 表的典型配法)。path 形态: -- 模块行四条(P95 终态;方案 §6.2 表在 P95 注记后只剩这四行是 UserBase 侧的事)。path 形态:
-- 填报 = /f/{code}(该快应用名下的表单 code,经 resolve 换 uuid——快应用不授权定义管理行,§3.4)
-- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段) -- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段)
-- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树") -- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树")
-- ⚠ 原「填报 /f/{code}」行**自 P95 起不再登记**:填报入口由中心按三层模型解析
-- (GET /api/form/my/fillable),模块行不参与;custom_id 300 段就此空置,不要再复用成别的行。
INSERT INTO app_module INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) (app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
SELECT * FROM ( SELECT * FROM (
SELECT @quick_app_id AS app_id, NULL AS parent_id, '填报' AS name, '300' AS custom_id, SELECT @quick_app_id AS app_id, NULL AS parent_id, '数据查看' AS name, '301' AS custom_id,
CONCAT('/f/', @quick_code, '_form') AS path, 'edit' AS icon, 1 AS level, 300 AS sort_no, CONCAT('/app/', @quick_code, '/data') AS path, 'category' AS icon, 1 AS level, 301 AS sort_no,
@operator AS create_user, NOW() AS create_time @operator AS create_user, NOW() AS create_time
UNION ALL SELECT @quick_app_id, NULL, '数据查看', '301', CONCAT('/app/', @quick_code, '/data'), 'category', 1, 301, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW() UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW() UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW() UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW()
@@ -109,8 +129,9 @@ WHERE @quick_app_id > 0
-- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查) -- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查)
SET @grp_admin = 0; -- ← 中心管理员组 SET @grp_admin = 0; -- ← 中心管理员组
SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它) SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它)
SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 导出锚点) SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 统计 + 导出锚点)
SET @grp_student = 0; -- ← 快应用学生组(只给填报行) SET @grp_student = 0; -- ← 快应用学生组:**P95 起只需 3.2 的应用开通(门户可见性),不再勾任何模块行**
-- 填报入口由中心按三层模型给(面向学生的表单自动出现在学生账号的「可填写」里)
-- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id) -- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id)
SET @school_id = 0; -- ← 目标学校 id SET @school_id = 0; -- ← 目标学校 id
@@ -124,12 +145,13 @@ SELECT g.group_id, a.app_id, @operator, NOW()
WHERE NOT EXISTS (SELECT 1 FROM sys_group_app_rel r WHERE NOT EXISTS (SELECT 1 FROM sys_group_app_rel r
WHERE r.group_id = a.group_id AND r.app_id = a.app_id); WHERE r.group_id = a.group_id AND r.app_id = a.app_id);
-- 3.3 中心模块授权:管理员组=前五行(除锚点),全员组=只"我的填写", -- 3.3 中心模块授权:管理员组=四个管理行(表单设计/数据管理/统计/应用设置),全员组=只"我的填写"。
-- 要看全校数据的组=额外勾"数据全量"锚点行(customId=fc:scope:all) -- v1.5 起**没有"要看全校数据就多勾一行"这回事**:数据范围升级改由中心的管理员两表给
-- (/admin/app 的角色设置 tab 加校级表单管理员,或表单列表/设计器给单表单管理员)。
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_admin, m.id, 0, @operator, NOW() SELECT @grp_admin, m.id, 0, @operator, NOW()
FROM app_module m FROM app_module m
WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND COALESCE(m.custom_id,'') <> 'fc:scope:all' WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.path <> '/my'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id); AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
@@ -138,27 +160,16 @@ SELECT @grp_all, m.id, 0, @operator, NOW()
WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my' WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id); AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id);
-- 数据全量锚点(V42 的授权动作;只给确实要看全校数据的组) -- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(导入 303 是 1.5 预留位不授)。
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) -- P95 起**学生组这一段整条撤销**:填报入口不再由模块行给(原"学生组=只给 /f/{code} 行"),
SELECT @grp_admin, m.id, 0, @operator, NOW() -- 学生所在组只需要 3.2 的**应用开通**(门户可见性与闸门照旧,V16 语义不变)——
FROM app_module m -- 能不能填哪张表由中心按三层模型判(后端 FillAccessService)。
WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.custom_id = 'fc:scope:all'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
-- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_teacher, m.id, 0, @operator, NOW() SELECT @grp_teacher, m.id, 0, @operator, NOW()
FROM app_module m FROM app_module m
WHERE m.app_id = @quick_app_id AND @grp_teacher > 0 AND COALESCE(m.custom_id,'') <> '303' -- 303=导入(1.5 预留位不授) WHERE m.app_id = @quick_app_id AND @grp_teacher > 0 AND COALESCE(m.custom_id,'') <> '303' -- 303=导入(1.5 预留位不授)
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id); AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id);
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_student, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @quick_app_id AND @grp_student > 0 AND m.level = 1 AND m.path LIKE '/f/%'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_student AND p.module_id = m.id);
-- 3.5 ⚠ 过渡口径(T9 报告 §5.4 末段 / 问题单 P78 附注,需裁决后才可能取消这一步): -- 3.5 ⚠ 过渡口径(T9 报告 §5.4 末段 / 问题单 P78 附注,需裁决后才可能取消这一步):
-- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己), -- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己),
-- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。 -- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。
@@ -172,21 +183,32 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW()
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行) -- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行)
-- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项,锚点行不出现) -- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项)
-- ②无授权用户:只见"我的填写" -- ②无授权用户:只见"我的填写"
-- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树 -- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通; -- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。 -- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(锚点判定生效,P76) -- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76)
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报 -- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用的「填报」档看到该应用下**面向学生**的表单
-- (P95:这张清单来自 GET /api/form/my/fillable,与快应用树里有没有 `/f/` 行无关——
-- 删掉存量 `/f/` 行后门户导航、「可填写」列表、分享直链三路都不受影响,见 P95 报告收尾清单)
-- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理),
-- 角色设置里加的人落 school_admin(数据范围升级,不是菜单行)——T10 V43/V46
-- ⑦P95 新判据(V52):快应用树里 `/f/` 行数=0(下面倒数第二条 chk),且中心段仍是 200~204 五行
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
SELECT '中心模块行数(期望 6)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id SELECT '中心模块行数(期望 5)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id
UNION ALL UNION ALL
SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id SELECT '快应用模块行数(P95 期望 4)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id
UNION ALL UNION ALL
SELECT '中心 path 空锚点数(期望 1)', COUNT(*) FROM app_module SELECT '快应用 /f/ 填报行数(P95 期望 0)', COUNT(*) FROM app_module
WHERE app_id = @quick_app_id AND path LIKE '/f/%'
UNION ALL
SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module
WHERE app_id = @center_app_id AND (path IS NULL OR path = '') WHERE app_id = @center_app_id AND (path IS NULL OR path = '')
UNION ALL UNION ALL
SELECT '快应用 path 空锚点数(期望 2=导入/导出)', COUNT(*) FROM app_module
WHERE app_id = @quick_app_id AND (path IS NULL OR path = '')
UNION ALL
SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id
UNION ALL UNION ALL
SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p
+64
View File
@@ -0,0 +1,64 @@
-- ============================================================
-- 表单中心 T10 · 管理员体系 DDL(MySQL 5.7 兼容复核版)
-- 范围:《管理员体系方案-20261015》§4 三笔 = school_admin / form_admin / answer 加列+加索引
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate
-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3 先例)。
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
-- ③InnoDB、无物理外键(方案 §7.7 逻辑外键约定:user_id→UserBase、form_uuid→form.uuid 都不建 FK);
-- ④可空列一律写 DEFAULT NULL,TEXT 类不出现(本脚本无大字段)。
-- P5/P15 裁决:实体继承 BaseEntity → 实表=方案 §4 草案 + create_by/update_by 两审计列;
-- 审计列在基类未标 nullable=false,故按建表实态写 DEFAULT NULL(FormCenterAuditListener 恒写 now())。
-- 重跑口径:MySQL 5.7 无 ADD COLUMN IF NOT EXISTS,本脚本【不是幂等的】——执行前先用
-- `SHOW COLUMNS FROM answer LIKE 'submitter_sch_id'` 确认列不存在;两表若已建则只跑第三条 ALTER。
-- 存量:生产零存量(H2 尚未执行、160 在线仍是 T5 旧版),无需回填;本地/perf 库的测试数据不强推
-- (NULL 行天然不匹配 SCHOOL 谓词=fail-closed,方案 §4 存量条款)。
-- 部署说明已同步补"加列"一笔(《表单中心160部署Runbook》§0)。
-- ============================================================
-- ① 校级表单管理员:数据范围扩展载体,取代已退役的 UserBase 侧 customId 权限锚点行(方案 §4①/§7)
CREATE TABLE `school_admin` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`sch_id` BIGINT NOT NULL COMMENT '租户键(=会话校;超管代查时=显式目标校)',
`user_id` INT NOT NULL COMMENT 'UserBase 用户主键(BoaUser.id),逻辑外键不建 FK',
`user_name` VARCHAR(64) NOT NULL COMMENT '姓名快照(列表展示免回查,权威在 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_school_admin` (`sch_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='校级表单管理员(方案 §5.1:本校表单范围=全部,基础表单范围=本校提交)';
-- ② 单表单管理员:指定表单的数据查看+导出(D3 裁决:不含编辑权)
CREATE TABLE `form_admin` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`form_uuid` VARCHAR(32) NOT NULL COMMENT '授权表单 uuid(跨版本不变,契约 §1)',
`sch_id` BIGINT NOT NULL COMMENT '冗余表单租户域(form.sch_id,基础表单=0),落库域复核用',
`user_id` INT NOT NULL,
`user_name` VARCHAR(64) NOT NULL COMMENT '姓名快照(列表展示免回查,权威在 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_form_admin` (`form_uuid`, `user_id`),
KEY `idx_form_admin_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='单表单管理员(方案 §5.1:指定表单=全部,基础表单上=本校提交)';
-- ③ 答卷加列:填报人会话学校(D2=B 的落点,SCHOOL 范围的谓词列)
-- NULL=未记录(存量行),按"不参与校级范围匹配"处理;updatable=false(落值后不随改)
ALTER TABLE `answer`
ADD COLUMN `submitter_sch_id` BIGINT NULL DEFAULT NULL
COMMENT '填报人会话学校(提交时从会话 currentRole.schId 落值;NULL=未记录,不参与校级范围匹配)'
AFTER `submitter_id`;
ALTER TABLE `answer` ADD KEY `idx_answer_submitter_sch` (`submitter_sch_id`);
-- 自验(执行后逐条核对,V47):
-- SHOW CREATE TABLE answer\G -- 应含 submitter_sch_id bigint NULL + KEY idx_answer_submitter_sch
-- EXPLAIN SELECT id FROM answer WHERE sch_id=3260 AND app_code='form-center' AND code='x'
-- AND is_deleted=0 AND submitter_sch_id=3260; -- SCHOOL 谓词应能走 key 列(V47 探针口径)
-- SELECT COUNT(*) FROM school_admin; -- 期望 0(新库)
-- SELECT COUNT(*) FROM form_admin; -- 期望 0(新库)
+74
View File
@@ -0,0 +1,74 @@
-- ============================================================
-- 表单中心 T13(契约 v2 §17.3)· 权限简化与匿名面 DDL(MySQL 5.7 兼容复核版)
-- 范围:《契约v2-权限简化与开放接入.md》§17.3 三笔 =
-- ① form.audience TINYINT→VARCHAR(64) + 存量迁移(1→'STUDENT'、2→'TEACHER',C1)
-- ② form 加 creator_id / frozen / anon_cookie_minutes(C16、§4.2、C7)
-- ③ answer 加 anon_ip / anon_ua + 复合索引 idx_answer_form_utime(C8/C12、§6.1)
-- 批次硬前置:**T13 部署前必须执行**(audience 列型改后新代码按 VARCHAR 码串读写,
-- ddl-auto=update 不会改已存在列的类型——本地/160 都必须手工跑本脚本)。
--
-- ⚠ 本脚本【不是幂等的】(沿 t10 先例:MySQL 5.7 无 ADD COLUMN IF NOT EXISTS / 无 MODIFY 判存在):
-- 执行前逐条先查——
-- SHOW COLUMNS FROM `form` LIKE 'audience'; -- Type 应为 tinyint;已是 varchar(64) 则跳过 ①
-- SHOW COLUMNS FROM `form` LIKE 'creator_id'; -- 有结果则跳过 ② 的第一条
-- SHOW COLUMNS FROM `form` LIKE 'frozen'; -- 有结果则跳过 ② 的第二条
-- SHOW COLUMNS FROM `form` LIKE 'anon_cookie_minutes'; -- 有结果则跳过 ② 的第三条
-- SHOW COLUMNS FROM `answer` LIKE 'anon_ip'; -- 有结果则跳过 ③ 的加列
-- SHOW INDEX FROM `answer` WHERE Key_name='idx_answer_form_utime'; -- 有结果则跳过 ③ 的加索引
-- 备份:`mysqld --single-transaction` 前先 mysqldump 本库(本地 bsch_formcenter / 160 容器网同名库)。
--
-- 5.7 复核要点(沿 t10):①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式 CHARSET/COLLATE;
-- ③InnoDB、无物理外键(creator_id→UserBase 用户主键、form_uuid→form.uuid 都是逻辑外键)。
--
-- 存量与语义:
-- · audience 迁移后**不允许出现逗号串之外的形态**;空串/NULL 视为脏数据,读侧按默认 STUDENT 解释(C1)。
-- · creator_id 存量=NULL:解析期按 `create_by`(账号名)兜底判定创建人(C16),
-- 一次性回填对照 UserBase 由人工/脚本另做(160 现存 form 行个位数);未回填行由兜底覆盖,不阻塞发布。
-- · frozen/anon_cookie_minutes 有默认值(0 / 10),存量行即"未冻结、默认 10 分钟",无需回填。
-- · anon_ip/anon_ua 只由 T15 匿名面写入,列先建不启用=部署即安全(§17.3 注)。
-- ============================================================
-- ① 面向人群改多值码串(C1:逗号分隔枚举码闭集 STUDENT/TEACHER/ANONYMOUS;禁止布尔补丁 D8)
-- 顺序刻意:先改列型(tinyint 值 1/2 转成字符串 '1'/'2'),再把数字串映射成枚举码。
ALTER TABLE `form`
MODIFY COLUMN `audience` VARCHAR(64) NOT NULL DEFAULT 'STUDENT'
COMMENT '面向人群(C1 多选,逗号分隔枚举码:STUDENT/TEACHER/ANONYMOUS;原 TINYINT 1/2 由本脚本迁移)';
UPDATE `form` SET `audience` = 'STUDENT' WHERE `audience` = '1';
UPDATE `form` SET `audience` = 'TEACHER' WHERE `audience` = '2';
-- ②-a 创建人归一列(C16:现网 create_by=账号名字符串,与两表 user_id 标识体系不同,建列归一)
ALTER TABLE `form`
ADD COLUMN `creator_id` BIGINT NULL DEFAULT NULL
COMMENT '创建人 user_id(BoaUser.id,新建时从会话落列;NULL=存量未回填,解析期按 create_by 账号名兜底)'
AFTER `name`;
-- ②-b 冻结开关(§4.2/D11:1=提交后禁编辑,双写通道同关;修正路径=撤回+重交)
ALTER TABLE `form`
ADD COLUMN `frozen` TINYINT NOT NULL DEFAULT 0
COMMENT '提交后禁编辑(1=开启,喂外部业务应用的表单建议开启;§4.2)'
AFTER `answer_no_enabled`;
-- ②-c 匿名防重窗口(C7:cookie 有效期分钟数,表单级可配,默认 10)
ALTER TABLE `form`
ADD COLUMN `anon_cookie_minutes` INT NOT NULL DEFAULT 10
COMMENT '匿名 cookie 防重窗口分钟数(C7,默认 10;仅 audience 含 ANONYMOUS 的表单有意义)'
AFTER `frozen`;
-- ③ 答卷匿名留痕两列 + 非冻结增量拉取索引(§6.1:现状 answer 表无任何 update_time 索引,已核实)
ALTER TABLE `answer`
ADD COLUMN `anon_ip` VARCHAR(64) NULL DEFAULT NULL
COMMENT '匿名提交来源 IP(C8/C12,仅匿名答卷填充,登录答卷恒 NULL)'
AFTER `idempotency_key`,
ADD COLUMN `anon_ua` VARCHAR(512) NULL DEFAULT NULL
COMMENT '匿名提交 User-Agent(C8/C12,只进独立列,不进 values_json)'
AFTER `anon_ip`;
ALTER TABLE `answer`
ADD KEY `idx_answer_form_utime` (`form_uuid`, `update_time`, `id`);
-- 自验(执行后逐条核对,V66/V67/V70 的库侧半边):
-- SHOW CREATE TABLE form\G -- audience varchar(64) NOT NULL DEFAULT 'STUDENT' + creator_id/frozen/anon_cookie_minutes
-- SHOW CREATE TABLE answer\G -- anon_ip/anon_ua + KEY idx_answer_form_utime(form_uuid,update_time,id)
-- SELECT audience, COUNT(*) FROM form GROUP BY audience; -- 只应出现 STUDENT/TEACHER/组合码串,不得再有 '1'/'2'
-- EXPLAIN SELECT id FROM answer WHERE form_uuid='<32位>' ORDER BY update_time, id; -- 应命中 idx_answer_form_utime
+39
View File
@@ -0,0 +1,39 @@
-- ============================================================
-- 表单中心 T16 · app 注册表 DDL(MySQL 5.7 兼容复核版)
-- 范围:《表单中心契约v2》§5.2/§17.2 一笔 = 建表 app_registry
-- 验收:V59(零角色字段)/ V65(uk(app_code) 全库唯一 + 保留字)/ V69(CRUD 与在用禁删)
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate
-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3/t10 先例)。
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
-- ③InnoDB、无物理外键——form.app_code 对注册表是<b>逻辑引用</b>(§5.2 明令"不校验存在性亦可建
-- 表单",建 FK 等于把这条改写成强引用,快应用还没注册就先建表的老数据会直接进不来);
-- ④本表无可空列(is_deleted/sch_id 都 NOT NULL,审计列按 P5 实态 DEFAULT NULL)。
-- **零角色字段(D14/V59 红线)**:全表只有 app_code / name / sch_id / is_deleted 四个业务列
-- + BaseEntity 四审计列。任何"谁能用这个 app""app 的管理员"之类的列都不许加——
-- 把授权塞回注册表正是 v2 要出清的东西(§19 作废-1/2 的同一件事)。
-- 唯一键口径:uk_app_code 是<b>全库唯一且不含 is_deleted</b>(§17.2 原文),所以软删后的 code
-- 仍被占用、不可复用(这层落地差额登记为问题单 P125,服务侧给可诊断文案而不是撞库 500)。
-- 非幂等:MySQL 5.7 无 ADD/CREATE IF NOT EXISTS 的评审友好写法(写了反而掩盖"表已存在但列不对"的漂移)。
-- 执行前先 `SHOW TABLES LIKE 'app_registry'` 确认不存在;存量:生产零存量(本表 T16 才建),无需回填。
-- ============================================================
CREATE TABLE `app_registry` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`app_code` VARCHAR(64) NOT NULL COMMENT '全局唯一人读 slug(C10 格式 ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$;保留字 form-center 不可注册)',
`name` VARCHAR(128) NOT NULL COMMENT '应用显示名(入口链接要贴去 UserBase,名字与那边对照用)',
`sch_id` BIGINT NOT NULL COMMENT '建立者会话校;0=平台级(仅超管可建,D14)',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删标记;名下存在 is_deleted=0 的表单行时禁删(§5.2 在用禁删)',
`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_app_code` (`app_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='快应用注册表(契约 v2 §5.2:四业务字段、零角色、无审核;form.app_code 为逻辑引用不建 FK)';
-- 自验(执行后逐条核对,V59/V65):
-- SHOW CREATE TABLE app_registry\G -- 应含 UNIQUE KEY `uk_app_code` (`app_code`),且列只有上面这些
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema=DATABASE() AND table_name='app_registry'
-- AND column_name REGEXP 'role|perm|admin|user_id'; -- 必须 0 行(V59 的机械判据)
+4
View File
@@ -40,6 +40,10 @@ CREATE TABLE `answer` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_answer_uuid` (`uuid`), UNIQUE KEY `uk_answer_uuid` (`uuid`),
UNIQUE KEY `uk_answer_idem` (`idempotency_key`), UNIQUE KEY `uk_answer_idem` (`idempotency_key`),
-- Bug#4 变更(本轮):报名号并发重号兜底。answer_no 是 count+1(当日已有号数)生成,
-- 同表单同日并发提交会算出同一个号;号是对外凭证,库级唯一才拦得住。
-- null 不参与唯一判定(未开报名号的表单该列恒 null,多行共存不受影响)。
UNIQUE KEY `uk_answer_no` (`sch_id`, `app_code`, `code`, `answer_no`),
KEY `idx_answer_form` (`sch_id`, `code`, `status`, `id`), KEY `idx_answer_form` (`sch_id`, `code`, `status`, `id`),
KEY `idx_answer_submitter` (`sch_id`, `submitter_id`), KEY `idx_answer_submitter` (`sch_id`, `submitter_id`),
KEY `idx_answer_subject` (`subject_id`), KEY `idx_answer_subject` (`subject_id`),
@@ -1,70 +0,0 @@
package com.yunhai.formcenter.common;
import com.yunhai.platform.utils.model.BoaModule;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。
*
* <p>模块行有两种身份,<b>必须分开取</b>:</p>
* <ul>
* <li>{@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);</li>
* <li>{@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位,
* 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。</li>
* </ul>
*
* <p>纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。</p>
*/
public final class FormModuleKeys {
private FormModuleKeys() {
}
/** 授权页面路由集合(path 空的权限锚点不在内) */
public static Set<String> paths(List<BoaModule> tree) {
Set<String> result = new LinkedHashSet<>();
collect(tree, true, result);
return result;
}
/**
* 权限锚点的 customId 集合:<b>只收 path 为空的节点</b>(契约 §8「锚点行形态」注记 / P47)。
*
* <p>数据范围升级位({@code fc:scope:all})按契约只应挂在<b>不进导航的 path 空行</b>上,
* 扫描必须按这个形态收:带 path 的导航行若误配了该值,不该把看数据的人悄悄升级成"全校全部"
* ——宽方向的误配同样是不受控的授权膨胀。</p>
*/
public static Set<String> anchorCustomIds(List<BoaModule> tree) {
Set<String> result = new LinkedHashSet<>();
collect(tree, false, result);
return result;
}
/**
* 递归展平(wantPath=true 取 path;false 取「path 空节点」的 customId)。
* 取不出值的节点自然被跳过,子节点照常下钻(锚点可以挂在分组层下面)。
*/
private static void collect(List<BoaModule> nodes, boolean wantPath, Set<String> out) {
if (nodes == null) {
return;
}
for (BoaModule node : nodes) {
if (node == null) {
continue;
}
String path = trimToNull(node.getPath());
String value = wantPath ? path : (path == null ? trimToNull(node.getCustomId()) : null);
if (value != null) {
out.add(value);
}
collect(node.getChildren(), wantPath, out);
}
}
private static String trimToNull(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
}
@@ -0,0 +1,37 @@
package com.yunhai.formcenter.config;
import com.yunhai.formcenter.service.AnswerDeliveryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
/**
* 快照投递的运行期装配(answer-delivery-v2 §3.4,TASK_2 B.4)。
*
* <p>{@code @EnableScheduling} 落在本类而不是应用主类:{@code FormCenterApplication} 不在本任务
* 写范围内,而 v2 的退避重投必须有周期扫描(broker 恢复后要把 PENDING/FAILED 追平)。</p>
*
* <p>扫描<b>不是</b>持久性机制——持久性是 outbox 行本身(同事务落库)。这里只是把"到点的行"
* 推给 broker;{@code afterCommit} 的即时唤醒({@link AnswerDeliveryService#wakeScan()})负责低延迟,
* 本方法负责兜底。未配 broker 时 {@code sendDueBatchQuietly} 直接返回 0,行留在库里。</p>
*/
@Slf4j
@Configuration
@EnableScheduling
@RequiredArgsConstructor
public class AnswerDeliveryConfiguration {
private final AnswerDeliveryService deliveryService;
/** 到点重投(退避序列由投递行的 next_retry_at 表达,这里只负责"闹钟") */
@Scheduled(fixedDelayString = "${fc.answer.delivery.scan-interval-ms:10000}",
initialDelayString = "${fc.answer.delivery.scan-initial-delay-ms:30000}")
public void scanDueDeliveries() {
int sent = deliveryService.sendDueBatchQuietly();
if (sent > 0 && log.isInfoEnabled()) {
log.info("[v2投递] 周期扫描已交出去 {} 条(等 broker confirm)", sent);
}
}
}
@@ -0,0 +1,94 @@
package com.yunhai.formcenter.config;
import com.yunhai.formcenter.service.AnswerReconcileService;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Scheduled;
/**
* 差异对账的运行期装配(TASK_3,answer-reconcile-v1 §7)。
*
* <p><b>本任务的配置全部独立</b>:键前缀 {@code fc.answer.reconcile.*},不写 {@code fc.answer.delivery.*}
* (TASK_2)、不写 {@code form-center.*}(TASK_1)的键,也不改那两个任务的任何文件。
* {@code @EnableScheduling} 早已由 {@link AnswerDeliveryConfiguration} 打开,本类只加自己的闹钟。</p>
*
* <p>两个开关默认都是<b>关</b>的:{@code schedule-enabled}(每日建任务)与 {@code dispatch-enabled}
* (物化+派发循环)。未授权订阅不产生任务,而未开调度同样不产生任务——调度可显式关闭这件事
* 是任务书的前置条件,不是可选优化。 {@code targets} 是<b>服务端登记</b>的消费方后台地址表,
* 键=已验签的 consumerAppKey:派发地址只能从这里来,浏览器/请求体里出现的任何 callbackUrl
* 在本任务里没有对应字段,也就无从采信。</p>
*/
@Slf4j
@Configuration
public class AnswerReconcileConfiguration {
/**
* 字段注入而非构造器注入:本类与 Service/DispatchClient/Properties 之间曾有构造环
* (Boot 2.6+ 默认禁环拒启,2026-10-22 160 部署实测)。环的物理解法是把
* answerReconcileProperties @Bean 拆到无依赖的 {@link AnswerReconcilePropertiesConfiguration};
* 字段注入保留——它把对 Service 的依赖推迟到属性填充阶段,让本类可以先于 Service 完成
* 自身装配。@Scheduled 回调运行时该字段必已就绪。
*/
@org.springframework.beans.factory.annotation.Autowired
private AnswerReconcileService reconcileService;
/** 清单物化与派发共用一个扫描闹钟:每 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;
/** 服务端显式覆盖/非标准消费者例外:appKey → http(s) 基址;标准消费者可留空,由 UserBase 注册 appCode 寻址。 */
private java.util.Map<String, String> targets = new java.util.LinkedHashMap<>();
/** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */
private String dispatchPath = "/oapi/form-sync/reconcile";
/** 信封 sourceAppKey:默认取本应用签名身份 */
private String sourceAppKey = "";
/** 扫描闹钟节奏(占位符与本类 @Scheduled 共用同一批键) */
private long scanIntervalMs = 15000L;
private long scanInitialDelayMs = 45000L;
}
}
@@ -0,0 +1,25 @@
package com.yunhai.formcenter.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 对账运行参数的注册位(TASK_3;2026-10-22 从 {@link AnswerReconcileConfiguration} 拆出)。
*
* <p>原 @Bean 挂在 {@link AnswerReconcileConfiguration} 里,而那个类又要注入
* {@code AnswerReconcileService}——Service 与 {@code AnswerReconcileDispatchClient}
* 都要注入本 Properties,于是形成 Configuration→Service→DispatchClient→Configuration
* 构造环。Spring Boot 2.6+ 默认禁止一切循环引用(字段注入也不豁免),160 部署实测
* 直接拒绝启动。本类无任何依赖,环从物理上消失;bean 名与类型都不变,
* 所有注入点零改动。</p>
*/
@Configuration
public class AnswerReconcilePropertiesConfiguration {
@Bean
@ConfigurationProperties(prefix = "fc.answer.reconcile")
public AnswerReconcileConfiguration.AnswerReconcileProperties answerReconcileProperties() {
return new AnswerReconcileConfiguration.AnswerReconcileProperties();
}
}
@@ -0,0 +1,149 @@
package com.yunhai.formcenter.controller.anon;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.AnonModels;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.service.AnonReplayGuard;
import com.yunhai.formcenter.service.AnswerService;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.formcenter.service.FormFileService;
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 org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 匿名填报面三端点(契约 v2 §3.2/C6/C7/C8,T15,验收 <b>V67</b>)。
*
* <pre>
* GET /anon/form/resolve?code=[&amp;appCode=] → 与登录面同构的钉版 + 内联 options
* POST /anon/form/submit → 同一条提交管线(V60),身份由表单归属推导
* POST /anon/form/upload?code=[&amp;appCode=] → 同登录面五条硬约束,落档租户=表单归属校
* </pre>
*
* <p><b>没有一件事在这个类里放宽</b>:值校验、附件归属、钉版、报名号、索引全部由
* {@link AnswerService#submitAnon} 进同一条管线;防探测文案与"表单不存在"同一条(C6/V54);
* 上传的大小上限、扩展名黑名单、随机 key 一样不少。这个类只负责三件 HTTP 侧的事——
* 取留痕 IP/UA、判防重窗口、下发 cookie。</p>
*/
@RestController
@RequestMapping("/anon/form")
@RequiredArgsConstructor
public class AnonFormController {
/** 本应用 app_code 缺省值(与登录面 resolve 同口径) */
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
/** 留痕列宽:{@code answer.anon_ip varchar(64)} / {@code anon_ua varchar(512)}(截断而不是拒提交) */
private static final int IP_MAX = 64;
private static final int UA_MAX = 512;
private final FormDefService defService;
private final AnswerService answerService;
private final FormFileService fileService;
private final AnonReplayGuard replayGuard;
/**
* 匿名解析(C6:{@code audience} 不含 ANONYMOUS、草稿、停用、软删、跨校歧义一律"表单不存在")。
* 出参与登录面 {@code /api/form/resolve} 同一个组装方法(契约 §3.2"同构下发")。
*/
@GetMapping("/resolve")
public FormModels.ResolvedFormView resolve(@RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode) {
return defService.resolveForAnon(appCode, code);
}
/**
* 匿名提交(C7/C8)。
*
* <p>顺序:先解析表单拿防重窗口 → 判 cookie → 进管线(管线内幂等命中优先于防重命中,
* 撞窗口才 CUSTOM(5))→ 真提交成功才重新签发 cookie(重放不续窗口)。</p>
*/
@PostMapping("/submit")
public AnswerModels.SubmitView submit(@RequestBody AnonModels.SubmitRequest body,
HttpServletRequest request,
HttpServletResponse response) {
String appCode = cleanAppCode(body.getAppCode());
String formCode = body.getFormCode() == null ? "" : body.getFormCode().trim();
// 这一次解析只为取 anon_cookie_minutes 与判 cookie;管线内部照常自己钉版解析(不省略、不分叉)
Form form = defService.resolvePublishedForAnon(appCode, formCode).form();
int windowMinutes = AnonReplayGuard.windowMinutes(form.getAnonCookieMinutes());
long remaining = replayGuard.throttleSeconds(form.getCode(), windowMinutes,
cookieValue(request, AnonReplayGuard.cookieName(form.getCode())));
AnswerModels.SubmitView view = answerService.submitAnon(body,
new AnonModels.Context(clientIp(request), userAgent(request), remaining));
if (!Boolean.TRUE.equals(view.getReplayed())) {
replayGuard.appendCookie(response, form.getCode(), replayGuard.issue(form.getCode()),
windowMinutes, request.getContextPath());
}
return view;
}
/**
* 匿名上传(契约 §3.2:其余与登录面全同,只有落档租户改为表单归属校)。
* {@code code} 在这里是<b>必填</b>的——它是匿名请求唯一能取到 {@code sch_id} 的来源,
* 而附件必须与答卷同域,否则提交时的 {@code assertAttachmentsOwned} 永远查不到这一行。
*/
@PostMapping("/upload")
public AnswerModels.FileView upload(@RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode,
@RequestParam("file") MultipartFile file) {
Form form = defService.resolvePublishedForAnon(cleanAppCode(appCode), code).form();
return fileService.uploadForTenant(form.getSchId(), file);
}
// ==================== HTTP 侧取数 ====================
/**
* 留痕 IP。{@code X-Forwarded-For} 取第一段(生产经 Caddy 反代,{@code getRemoteAddr()} 只会是代理地址)。
*
* <p><b>这个值是可伪造的</b>:契约把 IP/UA 定成"留痕"(C8/C12)而不是身份,防重也不依赖它
* (依赖的是服务端签发的 cookie)。真要把 IP 当鉴权依据,得先在反代侧配置可信跳数——
* 二期若需要,改的是这一处而不是判据。</p>
*/
private static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.trim().isEmpty()) {
return truncate(forwarded.split(",")[0].trim(), IP_MAX);
}
return truncate(request.getRemoteAddr(), IP_MAX);
}
private static String userAgent(HttpServletRequest request) {
return truncate(request.getHeader("User-Agent"), UA_MAX);
}
private static String cookieValue(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
private static String cleanAppCode(String appCode) {
return appCode == null || appCode.trim().isEmpty() ? DEFAULT_APP_CODE : appCode.trim();
}
private static String truncate(String raw, int max) {
if (raw == null) {
return null;
}
return raw.length() <= max ? raw : raw.substring(0, max);
}
}
@@ -0,0 +1,15 @@
/**
* 匿名面({@code /anon/**},契约 v2 C6/§3.2,T15)。
*
* <p><b>为什么路径族在 {@code /api} 之外</b>:CAS starter 的 {@code yh.sso} 只有 5 个键、
* <b>没有任何 exclude/豁免位</b>,其 6 个 Filter 只挂 {@code /api/*}(任务书 A11 取证)。
* 于是"匿名面挂在 {@code /api/form/anonymous/}"这条直觉写法不可行(契约 C6/P-A)——
* 放在 {@code /api} 下就必然进墙,而墙内未登录会话的响应是 302 引导不是数据。
* {@code /anon/**} 因此天然无墙,代价是部署侧要配套加反代/代理条目
* (生产 Caddy {@code /form-center/anon/*}、dev vite {@code /anon}),这是 T15 的残余轻运维项而不是代码缺口。</p>
*
* <p><b>本包的两条纪律</b>:① 方法签名里<b>不出现 {@code BoaUser}</b>(没有会话,任何身份注入都被禁止,
* 契约 §7 身份行的匿名例外就是"请求不携带任何身份字段");② 只读表单,不读任何按租户取的清单——
* 匿名填报人对 {@code /my}、数据面、oapi 一律无能力(C8/§2.1)。</p>
*/
package com.yunhai.formcenter.controller.anon;
@@ -0,0 +1,101 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.AdminModels;
import com.yunhai.formcenter.service.AdminDomainService;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
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;
/**
* 管理员两表的维护端点(T10 方案 §5.3;契约 v2 C4③ 换守卫)。
*
* <p><b>这两组端点属于维护面,不是数据面</b>:谁能进角色设置页由 {@code FormAccessPolicy.isSchoolAdmin}
* 决定(校管理员 ∪ 超管),加进去的人只升级数据范围({@code DataScopeService})——
* 原口径"两表不作入口凭证"仍然成立,只是入口判据从 UserBase 模块行换成了本库角色。
* 目标域一律走 {@code FormAdminGuard} 的会话域校验:校级只本校、超管可显式指定(含代查任意校),
* 名单里的人再逐个复核归属({@code AdminDomainService})。</p>
*
* <p>返回形态:写操作直接回<b>操作后的全量列表</b>(不分页,每校/每表单个位数),
* 前端省一次刷新请求,也就不会出现"删了最后一员、列表还留着那行"的中间态。</p>
*/
@RestController
@RequestMapping("/api/form/admin")
@RequiredArgsConstructor
public class AdminDomainController {
private final FormAdminGuard adminGuard;
private final FormDefService defService;
private final AdminDomainService adminService;
// ==================== 校级表单管理员 ====================
/** 全量列表(超管可用 {@code schId} 代查任意校) */
@GetMapping("/school-admins")
public List<AdminModels.AdminView> listSchoolAdmins(BoaUser user,
@RequestParam(required = false) Long schId) {
return adminService.listSchoolAdmins(
adminGuard.requirePlatformAdmin(user, schId));
}
@PostMapping("/school-admins")
public List<AdminModels.AdminView> addSchoolAdmins(BoaUser user,
@RequestBody AdminModels.AdminAddRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, body == null ? null : body.getSchId());
return adminService.addSchoolAdmins(domain, body == null ? null : body.getUsers());
}
@DeleteMapping("/school-admins/{id}")
public List<AdminModels.AdminView> removeSchoolAdmin(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("id") Long id) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return adminService.removeSchoolAdmin(domain, id);
}
// ==================== 单表单管理员 ====================
/** 表单定位走数据面同一条 {@code readDomainForm}:本校 ∪ 平台级可见,他校按"表单不存在"回答 */
@GetMapping("/forms/{formUuid}/admins")
public List<AdminModels.AdminView> listFormAdmins(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("formUuid") String formUuid) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return adminService.listFormAdmins(requireForm(domain, formUuid));
}
@PostMapping("/forms/{formUuid}/admins")
public List<AdminModels.AdminView> addFormAdmins(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("formUuid") String formUuid,
@RequestBody AdminModels.AdminAddRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
Form form = requireForm(domain, formUuid);
return adminService.addFormAdmins(form, domain, body == null ? null : body.getUsers());
}
@DeleteMapping("/forms/{formUuid}/admins/{id}")
public List<AdminModels.AdminView> removeFormAdmin(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("formUuid") String formUuid,
@PathVariable("id") Long id) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
Form form = requireForm(domain, formUuid);
return adminService.removeFormAdmin(form, id);
}
private Form requireForm(Long domain, String formUuid) {
return defService.readDomainForm(domain, formUuid);
}
}
@@ -0,0 +1,73 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.AppModels;
import com.yunhai.formcenter.service.AppRegistryService;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
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.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;
/**
* 快应用注册表端点族(契约 v2 C17/§5.2,T16)。
*
* <pre>
* GET /api/form/admin/apps → 本校 ∪ 平台级清单(每行附中心生成的 entryUrl)
* POST /api/form/admin/apps → 建(appCode + name;超管可 schId 指定域,含 0)
* PUT /api/form/admin/apps/{appCode} → 改名
* DELETE /api/form/admin/apps/{appCode} → 软删(在用禁删)
* </pre>
*
* <p>守卫与管理员两表同一把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③)——
* 模块行退役后"应用设置页进不进得来"就是这一个判据。寻址一律按 {@code appCode}(库内 id 不外露,V5),
* 因此改名以外的写操作没有"按 id 改 code"的面。</p>
*
* <p>返回形态沿两表那组端点:写操作直接回<b>操作后的单行</b>,前端自己更新那一行即可——
* 应用数量是个位数,不需要为了省一次刷新而把全量列表塞进每个写响应。</p>
*/
@RestController
@RequestMapping("/api/form/admin/apps")
@RequiredArgsConstructor
public class AppRegistryController {
private final FormAdminGuard adminGuard;
private final AppRegistryService appService;
/** 清单(超管可用 {@code schId} 显式换域看某校,与两表端点同一条代查口径) */
@GetMapping
public List<AppModels.AppView> list(BoaUser user, @RequestParam(required = false) Long schId) {
return appService.list(adminGuard.requirePlatformAdmin(user, schId));
}
@PostMapping
public AppModels.AppView create(BoaUser user, @RequestBody AppModels.CreateRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, body == null ? null : body.getSchId());
return appService.create(domain, body);
}
@PutMapping("/{appCode}")
public AppModels.AppView rename(BoaUser user,
@PathVariable("appCode") String appCode,
@RequestParam(required = false) Long schId,
@RequestBody AppModels.UpdateRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return appService.rename(domain, appCode, body);
}
@DeleteMapping("/{appCode}")
public AppModels.AppView delete(BoaUser user,
@PathVariable("appCode") String appCode,
@RequestParam(required = false) Long schId) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return appService.delete(domain, appCode);
}
}
@@ -0,0 +1,111 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.FormAccessModels.GrantRequest;
import com.yunhai.formcenter.model.FormAccessModels.GrantView;
import com.yunhai.formcenter.model.FormAccessModels.OwnerRequest;
import com.yunhai.formcenter.model.FormAccessModels.OwnershipView;
import com.yunhai.formcenter.model.FormAccessModels.RevokeRequest;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.FormAccessPolicy;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormConsumerAccessService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 消费应用授权管理面(v3 §2/§3,TASK_1)。
*
* <pre>
* GET /api/form/admin/form-access/owner → 这张表的资源拥有者(未绑定回 null)
* PUT /api/form/admin/form-access/owner → 设/换 owner 绑定(appKey 必须已在平台注册)
* GET /api/form/admin/form-access/grants → grant 清单(含已撤销行)
* POST /api/form/admin/form-access/grants → 授权/重申(读与订阅两把开关)
* POST /api/form/admin/form-access/grants/revoke → 撤销(enabled=0 + grantVersion 进位)
* </pre>
*
* <p>守卫沿用既有那把:{@link FormAdminGuard#requirePlatformAdmin}(校管理员 ∪ 超管,C4③,
* 与 app 注册、管理员两表同域),不新造一套管理面鉴权。为什么不是"创建人也能改":
* owner/grant 决定的是<b>别的能不能读这张表</b>,属于平台管理域而不是单张表的定义权——
* 创建人能把表建起来、发出去,但不能替全校做"把答卷给外部应用看"这个决定。</p>
*
* <p><b>身份核验的正向路径</b>(v3 §2):写进来的 appKey 必须是 UserBase 注册过的应用
* ({@link ConsumerPrincipalResolver#isRegisteredAppKey} 查平台密钥注册表,只判存在、不回显密钥)。
* 签名应用没有自报 owner 的通道——这张表上没有"应用自己声明我拥有某张表单"的入口。</p>
*/
@RestController
@RequestMapping("/api/form/admin/form-access")
@RequiredArgsConstructor
public class FormAccessController {
private final FormAdminGuard adminGuard;
private final FormConsumerAccessService accessService;
private final ConsumerPrincipalResolver callerResolver;
/** owner 绑定(读) */
@GetMapping("/owner")
public OwnershipView owner(BoaUser user,
@RequestParam("formUuid") String formUuid,
@RequestParam(required = false) Long schId) {
return accessService.findOwnership(adminGuard.requirePlatformAdmin(user, schId), formUuid);
}
/** owner 绑定(写):归属校由服务端按表单自己的 sch_id 落,不信入参 */
@PutMapping("/owner")
public OwnershipView setOwner(BoaUser user,
@RequestParam("formUuid") String formUuid,
@RequestParam(required = false) Long schId,
@RequestBody OwnerRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
String appKey = body == null ? null : body.getOwnerAppKey();
requireRegisteredAppKey(appKey, "ownerAppKey");
return accessService.setOwner(domain, formUuid, appKey, FormAccessPolicy.isSuperAdmin(user));
}
/** grant 清单 */
@GetMapping("/grants")
public List<GrantView> grants(BoaUser user,
@RequestParam("formUuid") String formUuid,
@RequestParam(required = false) Long schId) {
return accessService.listGrants(adminGuard.requirePlatformAdmin(user, schId), formUuid);
}
/** 授权/重申 */
@PostMapping("/grants")
public GrantView grant(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody GrantRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
requireRegisteredAppKey(body == null ? null : body.getConsumerAppKey(), "consumerAppKey");
return accessService.grant(domain, body, FormAccessPolicy.isSuperAdmin(user));
}
/** 撤销(不删行:留审计,grantVersion 进位让消费方认得出旧事件) */
@PostMapping("/grants/revoke")
public GrantView revoke(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody RevokeRequest body) {
return accessService.revoke(adminGuard.requirePlatformAdmin(user, schId), body,
FormAccessPolicy.isSuperAdmin(user));
}
private void requireRegisteredAppKey(String appKey, String field) {
if (appKey == null || appKey.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, field + " 必填(UserBase 注册分配的 appKey)");
}
if (!callerResolver.isRegisteredAppKey(appKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
field + "=" + appKey.trim() + " 不是平台已注册的应用身份,先在 UserBase 应用注册里核对 appKey");
}
}
}
@@ -2,6 +2,7 @@ package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.common.FormSchemaValidator; import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.model.FormModels; import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.service.FormAccessPolicy;
import com.yunhai.formcenter.service.FormAdminGuard; import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormDefService; import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.formcenter.service.FormDictService; import com.yunhai.formcenter.service.FormDictService;
@@ -44,21 +45,21 @@ public class FormAdminController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam(required = false) String appCode, @RequestParam(required = false) String appCode,
@RequestParam(required = false) Integer status) { @RequestParam(required = false) Integer status) {
return defService.list(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), appCode, status); return defService.list(adminGuard.requireCreate(user, schId), appCode, status);
} }
@GetMapping("/get") @GetMapping("/get")
public FormModels.FormView get(BoaUser user, public FormModels.FormView get(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) { @RequestParam("uuid") String uuid) {
return defService.get(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); return defService.get(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
} }
@GetMapping("/versions") @GetMapping("/versions")
public List<FormModels.VersionView> versions(BoaUser user, public List<FormModels.VersionView> versions(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) { @RequestParam("uuid") String uuid) {
return defService.versions(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); return defService.versions(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
} }
/** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */ /** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */
@@ -67,7 +68,7 @@ public class FormAdminController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid, @RequestParam("uuid") String uuid,
@RequestParam("version") Integer version) { @RequestParam("version") Integer version) {
return defService.version(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, version); return defService.version(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid, version);
} }
/** /**
@@ -79,7 +80,7 @@ public class FormAdminController {
public List<String> filterFields(BoaUser user, public List<String> filterFields(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) { @RequestParam("uuid") String uuid) {
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); FormModels.VersionView current = currentPublishedVersion(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
return current == null ? java.util.Collections.emptyList() return current == null ? java.util.Collections.emptyList()
: new java.util.ArrayList<>(FormSchemaValidator.filterableFields(current.getSchemaJson()).keySet()); : new java.util.ArrayList<>(FormSchemaValidator.filterableFields(current.getSchemaJson()).keySet());
} }
@@ -88,7 +89,14 @@ public class FormAdminController {
public FormModels.FormView create(BoaUser user, public FormModels.FormView create(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCreateRequest body) { @RequestBody FormModels.FormCreateRequest body) {
return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); return defService.create(adminGuard.requireCreate(user, schId),
body, creatorId(user));
}
/** 创建人会话自取(C16:创建人自动持有该表单 ALL 档,G1"创建人保留定义权"的落列来源) */
private static Long creatorId(BoaUser user) {
Integer uid = FormAccessPolicy.uid(user);
return uid == null ? null : Long.valueOf(uid);
} }
/** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */ /** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */
@@ -96,7 +104,7 @@ public class FormAdminController {
public FormModels.FormView save(BoaUser user, public FormModels.FormView save(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody FormModels.FormSaveRequest body) { @RequestBody FormModels.FormSaveRequest body) {
return defService.save(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); return defService.save(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body);
} }
/** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */ /** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */
@@ -105,7 +113,7 @@ public class FormAdminController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid, @RequestParam("uuid") String uuid,
@RequestParam(value = "changeLog", required = false) String changeLog) { @RequestParam(value = "changeLog", required = false) String changeLog) {
return defService.publish(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, changeLog); return defService.publish(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid, changeLog);
} }
/** 停用:只断填报入口,数据可读可导(方案 §5.4) */ /** 停用:只断填报入口,数据可读可导(方案 §5.4) */
@@ -113,7 +121,7 @@ public class FormAdminController {
public FormModels.FormView disable(BoaUser user, public FormModels.FormView disable(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) { @RequestParam("uuid") String uuid) {
return defService.disable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); return defService.disable(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
} }
/** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */ /** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */
@@ -121,7 +129,7 @@ public class FormAdminController {
public FormModels.FormView enable(BoaUser user, public FormModels.FormView enable(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) { @RequestParam("uuid") String uuid) {
return defService.enable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); return defService.enable(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
} }
/** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */ /** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */
@@ -129,23 +137,25 @@ public class FormAdminController {
public void softDelete(BoaUser user, public void softDelete(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) { @RequestParam("uuid") String uuid) {
defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); defService.softDelete(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
} }
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行 */ /** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行;创建人=点副本的人 */
@PostMapping("/copy") @PostMapping("/copy")
public FormModels.FormView copy(BoaUser user, public FormModels.FormView copy(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCopyRequest body) { @RequestBody FormModels.FormCopyRequest body) {
return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); return defService.copy(adminGuard.requireDefinition(user, schId,
defService.findFormForGuard(body.getUuid())),
body, creatorId(user));
} }
/** 表单级配置:面向人群/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签 */ /** 表单级配置:面向人群(数组,C1)/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签/冻结开关/匿名防重窗口 */
@PostMapping("/config") @PostMapping("/config")
public FormModels.FormView config(BoaUser user, public FormModels.FormView config(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody FormModels.FormConfigRequest body) { @RequestBody FormModels.FormConfigRequest body) {
return defService.updateConfig(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); return defService.updateConfig(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body);
} }
/** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */ /** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */
@@ -153,44 +163,44 @@ public class FormAdminController {
public FormModels.FormView statusDefs(BoaUser user, public FormModels.FormView statusDefs(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody FormModels.StatusDefsRequest body) { @RequestBody FormModels.StatusDefsRequest body) {
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); return defService.updateStatusDefs(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body);
} }
// ==================== 选项字典(契约 §2.1) ==================== // ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ====================
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */ /** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
@GetMapping("/dict/list") @GetMapping("/dict/list")
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) { public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) {
return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT)); return dictService.listOwn(adminGuard.requirePlatformAdmin(user, schId));
} }
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */ /** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
@GetMapping("/dict/selectable") @GetMapping("/dict/selectable")
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) { public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT)); return dictService.listSelectable(adminGuard.requirePlatformAdmin(user, schId));
} }
@PostMapping("/dict/save") @PostMapping("/dict/save")
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode(), body.getName()); return dictService.saveDict(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getCode(), body.getName());
} }
@PostMapping("/dict/delete") @PostMapping("/dict/delete")
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode()); dictService.deleteDict(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getCode());
} }
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */ /** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
@PostMapping("/dict/item/save") @PostMapping("/dict/item/save")
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) { public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), return dictService.saveItem(adminGuard.requirePlatformAdmin(user, body.getSchId()),
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort()); body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
} }
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */ /** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
@PostMapping("/dict/item/delete") @PostMapping("/dict/item/delete")
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) { public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getDictCode(), body.getValue()); dictService.deleteItem(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getDictCode(), body.getValue());
} }
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) { private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
@@ -17,11 +17,11 @@ import java.util.List;
* 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。 * 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。
* *
* <p>这里<b>没有</b>编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用" * <p>这里<b>没有</b>编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用"
* 的实现形态就是<b>不存在这些端点</b>,加上数据管理模块行守卫给出的 403)。改状态同理: * 的实现形态就是<b>不存在这些端点</b>,加上数据面入口的 ALL 档守卫给出的 403)。改状态同理:
* 填表人在接口层即被拒(V14)。</p> * 填表人在接口层即被拒(V14)。</p>
* *
* <p><b>两层权限</b>(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data}, * <p><b>两层权限</b>(T4 / P30 收紧点;契约 v2 C4②):控制器按<b>该表单 ALL 档</b>把关
* 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次<b>行级数据范围</b> * (答卷寻址的端点先反查所属表单再判),决定"能不能用数据管理接口";服务内每条答卷再过一次<b>行级数据范围</b>
* (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动, * (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动,
* 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。</p> * 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。</p>
@@ -41,7 +41,7 @@ public class FormAnswerAdminController {
public void edit(BoaUser user, public void edit(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody AnswerModels.EditRequest body) { @RequestBody AnswerModels.EditRequest body) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireDataByAnswer(user, schId, body == null ? null : body.getAnswerId());
adminService.edit(user, domain, body); adminService.edit(user, domain, body);
} }
@@ -50,7 +50,7 @@ public class FormAnswerAdminController {
public void softDelete(BoaUser user, public void softDelete(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) { @RequestParam("answerId") String answerId) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireDataByAnswer(user, schId, answerId);
adminService.softDelete(user, domain, answerId); adminService.softDelete(user, domain, answerId);
} }
@@ -59,7 +59,7 @@ public class FormAnswerAdminController {
public void changeStatus(BoaUser user, public void changeStatus(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody AnswerModels.ChangeStatusRequest body) { @RequestBody AnswerModels.ChangeStatusRequest body) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireDataByAnswer(user, schId, body == null ? null : body.getAnswerId());
adminService.changeStatus(user, domain, body); adminService.changeStatus(user, domain, body);
} }
@@ -68,7 +68,8 @@ public class FormAnswerAdminController {
public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser user, public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody AnswerModels.BatchRequest body) { @RequestBody AnswerModels.BatchRequest body) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireDataByAnswers(user, schId,
body == null ? java.util.Collections.<String>emptyList() : body.getAnswerIds());
return adminService.batchSoftDelete(user, domain, body); return adminService.batchSoftDelete(user, domain, body);
} }
@@ -77,7 +78,8 @@ public class FormAnswerAdminController {
public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser user, public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody AnswerModels.BatchRequest body) { @RequestBody AnswerModels.BatchRequest body) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireDataByAnswers(user, schId,
body == null ? java.util.Collections.<String>emptyList() : body.getAnswerIds());
return adminService.batchChangeStatus(user, domain, body); return adminService.batchChangeStatus(user, domain, body);
} }
} }
@@ -1,11 +1,14 @@
package com.yunhai.formcenter.controller.api; package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.ImportModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.service.AnswerExportService; import com.yunhai.formcenter.service.AnswerExportService;
import com.yunhai.formcenter.service.AnswerImportService;
import com.yunhai.formcenter.service.AnswerReadService; import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.AnswerStatsService; import com.yunhai.formcenter.service.AnswerStatsService;
import com.yunhai.formcenter.service.DataScopeService; import com.yunhai.formcenter.service.DataScopeService;
import com.yunhai.formcenter.service.FormAdminGuard; import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
@@ -17,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
@@ -25,17 +29,18 @@ import java.net.URLEncoder;
import java.util.List; import java.util.List;
/** /**
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。 * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。
* *
* <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p> * <p><b>权限两层各管一件事,缺一不可</b>(契约 v2 C4② 换了第①层的判据来源):</p>
* <ul> * <ul>
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data}, * <li>能不能进这个接口=<b>该表单 ALL 档</b>({@link FormAdminGuard#requireData};
* 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");</li> * 统计/列表/筛选/导出都吃同一道门。详情按 answerId 寻址、入口时手上还没有表单对象,
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId, * 故先由 uuid 反查所属表单再判({@code requireDataByAnswer});</li>
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 两档解析 + 创建人/两表/角色,
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li> * 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li>
* </ul> * </ul>
* *
* <p>所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校—— * <p>所以"拿到 ALL 档入口的人"看到的仍是他范围内的数据——ALL 档在平台级表单上会收窄成本校提交行——
* 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。</p> * 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。</p>
* *
* <p>身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径); * <p>身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径);
@@ -49,9 +54,11 @@ import java.util.List;
public class FormAnswerReadController { public class FormAnswerReadController {
private final FormAdminGuard adminGuard; private final FormAdminGuard adminGuard;
private final FormDefService defService;
private final AnswerReadService readService; private final AnswerReadService readService;
private final AnswerStatsService statsService; private final AnswerStatsService statsService;
private final AnswerExportService exportService; private final AnswerExportService exportService;
private final AnswerImportService importService;
/** 两段式列表(keyset 游标对外是 uuid,排序恒为提交时间序) */ /** 两段式列表(keyset 游标对外是 uuid,排序恒为提交时间序) */
@PostMapping("/list") @PostMapping("/list")
@@ -59,7 +66,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody ReadModels.ListRequest body) { @RequestBody ReadModels.ListRequest body) {
requireFormUuid(body.getFormUuid()); requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid()));
return readService.list(user, domain, body); return readService.list(user, domain, body);
} }
@@ -68,7 +75,9 @@ public class FormAnswerReadController {
public ReadModels.DetailView detail(BoaUser user, public ReadModels.DetailView detail(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) { @RequestParam("answerId") String answerId) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); // 入口时只有答卷 uuid、表单还没定位,故按"数据管理或统计任一"取域;
// 域内可见性由 readService.detail 里的同一份范围规格 + visible() 行级把关(与列表同集,V27)
Long domain = adminGuard.requireDataByAnswer(user, schId, answerId);
return readService.detail(user, domain, answerId); return readService.detail(user, domain, answerId);
} }
@@ -78,7 +87,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid) { @RequestParam("formUuid") String formUuid) {
requireFormUuid(formUuid); requireFormUuid(formUuid);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid));
return readService.filterFields(domain, formUuid); return readService.filterFields(domain, formUuid);
} }
@@ -88,8 +97,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody ReadModels.QueryRequest body) { @RequestBody ReadModels.QueryRequest body) {
requireFormUuid(body.getFormUuid()); requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdminAny(user, schId, Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid()));
FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
return statsService.stats(user, domain, body); return statsService.stats(user, domain, body);
} }
@@ -106,7 +114,7 @@ public class FormAnswerReadController {
@RequestBody ReadModels.QueryRequest body, @RequestBody ReadModels.QueryRequest body,
HttpServletResponse response) throws IOException { HttpServletResponse response) throws IOException {
requireFormUuid(body.getFormUuid()); requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid()));
String fileName = "form-answers.xlsx"; String fileName = "form-answers.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8");
@@ -118,6 +126,46 @@ public class FormAnswerReadController {
} }
} }
/**
* 导入模板下载(契约 v2 C14,T18)。表头=当前发布版本的字段 label,第一列固定"被记录人ID",
* 第二行是给人看的说明;与 {@link #importAnswers} 的解析共用同一份映射(都在 {@code AnswerImportService})。
*
* <p>守卫与导出同一把(该表单 <b>ALL 档</b>)——模板本身就是数据面产物(它给出这张表的字段清单),
* 且拿到模板的人下一步就是要写数据。未发布的表单没有可导入的字段定义,服务侧直接拒。</p>
*/
@GetMapping("/import-template")
public void importTemplate(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid,
HttpServletResponse response) throws IOException {
requireFormUuid(formUuid);
Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid));
String fileName = "form-import-template.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition",
"attachment; filename=" + fileName + "; filename*=UTF-8''" + encode(fileName));
try (OutputStream out = response.getOutputStream()) {
importService.template(domain, formUuid, out);
}
}
/**
* 批量导入(C14/C13,T18):multipart xlsx,<b>逐行走提交管线</b>(V60),响应逐行结果不静默坏行。
*
* <p>这条端点返回 JSON 信封(与导出不同:它不是文件流)。幂等键由服务端按 C13 确定性生成,
* 所以"重复导入同一个文件"是逐行 200+原 answerId,而不是重复落库。</p>
*/
@PostMapping("/import")
public ImportModels.Result importAnswers(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid,
@RequestParam("file") MultipartFile file) {
requireFormUuid(formUuid);
Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid));
return importService.importAnswers(user, domain, formUuid, file);
}
private static void requireFormUuid(String formUuid) { private static void requireFormUuid(String formUuid) {
if (formUuid == null || formUuid.trim().isEmpty()) { if (formUuid == null || formUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空(数据面按表单 uuid 寻址,契约 §1)"); throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空(数据面按表单 uuid 寻址,契约 §1)");
@@ -1,47 +1,36 @@
package com.yunhai.formcenter.controller.api; package com.yunhai.formcenter.controller.api;
import com.yunhai.platform.openapi.base.YschBaseOpenApi; import com.yunhai.formcenter.model.SchoolModels;
import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.SchoolUserTreeService;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 前端壳基础端点(对齐 UserBase BaseController 的 /api/base/* 形态):GetUserInfo / GetUserMod。 * 前端壳基础端点(对齐 UserBase BaseController 的 {@code /api/base/*} 形态)。
* <p>GetUserMod 与其他子应用(Notice / svc-hall / 事项库 Cms / flow-center)统一走平台下发:经 SDK *
* {@code YschBaseOpenApi.getUserModule} 取当前用户在本应用({@code yh.app-auth.app-key})下的 * <p>契约 v2 §2.4 之后本类只剩两个端点:</p>
* 已授权模块<b>树</b>,层级由平台契约 {@code BoaModule.children} 承载,本侧仅映射为前端 layout * <ul>
* 期望的字段形状({@code page/modId/children})。 * <li>{@code GetUserInfo}——会话用户本体,App.vue 引导的唯一请求(左导航改由三支路能力位自解析:
* <p><b>target appCode(快应用上下文,方案 §6.2 / 任务书 T8)</b>:{@code /app/{appCode}/} 需要的是 * {@code /api/form/my/fillable}、{@code /api/form/my/managed}、{@code /api/form/my/capabilities});</li>
* <b>那个快应用</b>的模块树,而不是表单中心自己的。它依赖 UserBase {@code /oapi/user/modules} 的 * <li>{@code GetSchoolAll}——本校组织树(教师版),管理员两表选人弹窗的数据源;
* 可选 appCode 参数(跨项目触点 H4),SDK 侧目前只有 * 守卫从"应用设置 ∪ 表单设计两条模块行"换成 C4③ 的<b>校管理员 ∪ 超管</b>。</li>
* {@code getUserModule(int userId, Integer schId)} 一个形态、<b>无 appCode 入参</b>。 * </ul>
* H4 上线前本端点对该请求<b>返回空树并打 warn</b>——不静默返回本应用菜单, *
* 那会把「请求 A 应用的树」伪装成「A 应用就是这些菜单」。 * <p><b>{@code GetUserMod} 已随模块行整条退役而删除</b>(§19 作废-10):中心不再消费 UserBase 的
* 授权模块树,B14(快应用目标应用树缺口)与跨项目触点 H4 随之消失——UserBase 侧不需要为表单中心
* 登记任何模块行,也不需要给 {@code /oapi/user/modules} 加 appCode 参数。</p>
*/ */
@Slf4j
@RestController @RestController
@RequiredArgsConstructor
public class FormBaseController { public class FormBaseController {
/** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */ private final FormAdminGuard adminGuard;
private static final String APP_CODE = "form-center"; private final SchoolUserTreeService schoolUserTreeService;
/**
* appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。
* 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。
*/
static boolean isForeignTarget(String appCode) {
String target = appCode == null ? "" : appCode.trim();
return !target.isEmpty() && !APP_CODE.equalsIgnoreCase(target);
}
@GetMapping("/api/base/GetUserInfo") @GetMapping("/api/base/GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) { public BoaUser getUserInfo(BoaUser authUserWithDept) {
@@ -49,75 +38,15 @@ public class FormBaseController {
} }
/** /**
* 当前用户已授权模块树(左侧菜单)。 * 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。
* <p><b>依赖 UserBase 侧数据</b>:本应用的 {@code app_module} 行({@code path} 须逐字对上
* front 路由,见 T5 的 router/routes.ts)+ 角色/组授权,二者缺一即返回空菜单。
* 这里<b>不保留本地 MENUS 兜底</b>——静默回退会把「平台未配模块/未授权」伪装成正常,
* 与其他子应用口径一致,配置缺失应表现为空菜单并被发现。
* *
* @param appCode 可选,目标应用的 app_code。缺省或等于 {@code form-center} 时返回本应用的树; * <p>没有 {@code schId} 入参:域只从会话推导(契约 §7 身份行),因此候选天然限在本校树里
* 指向其它应用时走 H4(UserBase 侧尚未上线)——返回空树并打 warn。 * (跨校的人在接口层就给不出来)。T10 补件的原因是 UserBase 侧没有"按 app 过滤的组织树"端点,
* 平台现行做法就是子应用自己代理组树——notice 与 flow-center 各有一份,本端点是同一口径的第三份。</p>
*/ */
@GetMapping("/api/base/GetUserMod") @GetMapping("/api/base/GetSchoolAll")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept, public List<SchoolModels.Node> getSchoolAll(BoaUser authUserWithDept) {
@RequestParam(required = false) String appCode) { Long domain = adminGuard.requirePlatformAdmin(authUserWithDept, null);
if (isForeignTarget(appCode)) { return schoolUserTreeService.teacherTree(domain);
log.warn("[GetUserMod] 请求目标应用模块树 appCode={},UserBase /oapi/user/modules 的 "
+ "appCode 参数(H4)未上线,SDK 无对应入参,按缺口返回空树", appCode);
return Collections.emptyList();
}
if (authUserWithDept == null) {
return Collections.emptyList();
}
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
List<BoaModule> modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schId);
if (modules == null || modules.isEmpty()) {
return Collections.emptyList();
}
// 用户中心已按 BoaModule.children 下发模块树:本地不再重组层级,仅做前端字段改名映射
return toFrontendTree(modules);
}
/**
* BoaModule 树 → 前端 layout 期望的字段形状(递归,<b>只改名不组树</b>;Notice/svc-hall/Cms 同款口径)。<br>
* 层级与兄弟顺序均由用户中心决定(customId 升序),本侧不重排;叶子 children 为空列表。<br>
* 包级可见而非 private:供同包单测直接覆盖映射,避免为测试改写 {@code YschBaseOpenApi} 全局静态客户端。
*/
static List<Map<String, Object>> toFrontendTree(List<BoaModule> modules) {
List<Map<String, Object>> result = new ArrayList<>();
if (modules == null || modules.isEmpty()) {
return result;
}
for (BoaModule m : modules) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", m.getId());
node.put("modId", resolveModId(m));
node.put("name", m.getName());
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
node.put("page", m.getPath() != null ? m.getPath() : "");
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
node.put("usable", true);
node.put("children", toFrontendTree(m.getChildren()));
result.add(node);
}
return result;
}
/**
* modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的
* {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。
* 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。
* 表单中心的 {@code fc:scope:all}(数据范围权限锚点,契约 §8)是<b>非数字</b> customId,
* 且 path 为空、不进导航,故只由后端扫描授权集合消费,不依赖本映射结果。
*/
private static Object resolveModId(BoaModule m) {
String customId = m.getCustomId();
if (customId != null && customId.trim().matches("\\d+")) {
return Integer.valueOf(customId.trim());
}
return m.getId();
} }
} }
@@ -3,6 +3,8 @@ package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.MyModels; import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.service.AnswerReadService; import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.FillableFormService;
import com.yunhai.formcenter.service.FormAccessPolicy;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
@@ -27,7 +29,10 @@ import java.util.List;
* <li><b>数据面身份只用 uuid</b>:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);</li> * <li><b>数据面身份只用 uuid</b>:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);</li>
* <li><b>权限闸门</b>:普通用户只能看/改/删<b>自己提交</b>的答卷(submitter_id=会话用户), * <li><b>权限闸门</b>:普通用户只能看/改/删<b>自己提交</b>的答卷(submitter_id=会话用户),
* "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作); * "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作);
* 有管理权者另有 /api/form/admin/data/**(T4 已落地,scope 矩阵生效),两套口径不混用;</li> * 管理动作另有 {@code /api/form/admin/data/**}(按该表单 ALL 档把关,契约 v2 C4②),两套口径不混用;</li>
* <li><b>菜单自解析的三支路数据源都挂在本前缀下</b>(§2.4/D5):{@code /fillable}(可填)、
* {@code /managed}(我管理的)、{@code /capabilities}(校管理员能力位)——
* UserBase 模块行整条退役后,前端不再从授权树里挑菜单,也不自行判权(只看这三把接口给什么);</li>
* <li><b>本控制器没有状态端点</b>:填表人改状态在接口层即不存在(V14 的实现形态)。</li> * <li><b>本控制器没有状态端点</b>:填表人改状态在接口层即不存在(V14 的实现形态)。</li>
* </ul> * </ul>
*/ */
@@ -38,6 +43,8 @@ import java.util.List;
public class FormMyAnswerController { public class FormMyAnswerController {
private final AnswerReadService readService; private final AnswerReadService readService;
private final FillableFormService fillableService;
private final FormAccessPolicy accessPolicy;
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */ /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
@GetMapping("/list") @GetMapping("/list")
@@ -53,8 +60,8 @@ public class FormMyAnswerController {
* *
* <p>与 {@code /my/about} 是<b>两个端点、两个谓词</b>,不是一个端点加 view 参数 * <p>与 {@code /my/about} 是<b>两个端点、两个谓词</b>,不是一个端点加 view 参数
* (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。 * (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。
* 这里<b>不看模块行、不看契约 §8 范围矩阵</b>——数据管理面那套范围管的是"管理员能看谁的行", * 这里<b>不看角色、不看两档范围</b>——数据管理面那套范围管的是"管理员能看谁的行",
* 本面管的是"我自己的行",两套口径混用会让填表人不授权模块行就看不见自己的数据。</p> * 本面管的是"我自己的行",两套口径混用会让填表人不进管理面就看不见自己的数据。</p>
*/ */
@GetMapping("/submit-list") @GetMapping("/submit-list")
public List<MyModels.ItemView> submitList(BoaUser user) { public List<MyModels.ItemView> submitList(BoaUser user) {
@@ -96,6 +103,53 @@ public class FormMyAnswerController {
return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary()); return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary());
} }
/**
* 「可填写」列表({@code V50},行形状按契约 v2 <b>C2</b>):<b>填报入口</b>清单,不是答卷清单——
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行过 <b>audience 门</b>(身份类型 ∈ 面向人群),
* 不中的不入集。
*
* <p>这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉:
* 谁能填什么由中心按 {@code audience × 会话身份} 自解析,UserBase 侧不需要逐表单配模块行。
* <b>它不看数据范围</b>({@code DataScopeService}):列表里出现某张表,不代表进去能看到别人的行。</p>
*
* @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单
*/
@GetMapping("/fillable")
public List<MyModels.FillableRow> fillable(BoaUser user,
@RequestParam(value = "appCode", required = false) String appCode) {
requireSession(user);
return fillableService.listFillable(user, appCode);
}
/**
* 「可填写」清单的<b>管理面同族</b>端点(契约 v2 C3,{@code GET /api/form/my/managed}):
* 创建人 ∪ form_admin 命中的行,行形状=fillable 行 + {@code role} 来源标记。
*
* <p>它是 D5"菜单自解析"的第二支路数据源。与 {@code /fillable} 一样<b>不接受外部传身份</b>,
* 归属判定全在服务侧按会话算({@link FillableFormService#listManaged})。</p>
*/
@GetMapping("/managed")
public List<MyModels.ManagedRow> managed(BoaUser user,
@RequestParam(value = "appCode", required = false) String appCode) {
requireSession(user);
return fillableService.listManaged(user, appCode);
}
/**
* 能力位(契约 v2 §2.4 第三支路):前端"系统设置"入口的唯一数据源。
*
* <p>只有 {@code schoolAdmin} 一个布尔——超管恒 true(§2.1)。它<b>不是权限</b>:
* 管理员两表、快应用注册、{@code GetSchoolAll} 各自在接口层按 C4③ 的角色门把关,
* 这里只回答"要不要给这个人画这个入口"。</p>
*/
@GetMapping("/capabilities")
public MyModels.Capabilities capabilities(BoaUser user) {
requireSession(user);
MyModels.Capabilities caps = new MyModels.Capabilities();
caps.setSchoolAdmin(accessPolicy.isSchoolAdmin(user));
return caps;
}
private static void requireSession(BoaUser user) { private static void requireSession(BoaUser user) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
@@ -1,9 +1,8 @@
package com.yunhai.formcenter.controller.api; package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.FormModels; import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.service.FormAccessPolicy;
import com.yunhai.formcenter.service.FormDefService; import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -43,18 +42,7 @@ public class FormResolveController {
public FormModels.ResolvedFormView resolve(BoaUser user, public FormModels.ResolvedFormView resolve(BoaUser user,
@RequestParam("code") String code, @RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode) { @RequestParam(value = "appCode", required = false) String appCode) {
return defService.resolve(requireSessionSchId(user), return defService.resolve(FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,无法解析表单"),
appCode == null || appCode.trim().isEmpty() ? DEFAULT_APP_CODE : appCode.trim(), code); appCode == null || appCode.trim().isEmpty() ? DEFAULT_APP_CODE : appCode.trim(), code);
} }
/** 会话校(无学校归属的身份不能寻址表单——区域组/根直属用户不是填报主体) */
private Long requireSessionSchId(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,无法解析表单");
}
return Long.valueOf(user.getCurrentRole().getSchId());
}
} }
@@ -0,0 +1,117 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.AnswerDeliveryModels.DeliveryStateView;
import com.yunhai.formcenter.model.AnswerDeliveryModels.FailedDeliveryPage;
import com.yunhai.formcenter.model.FormSyncModels.ReplayRequest;
import com.yunhai.formcenter.model.FormSyncModels.ResultView;
import com.yunhai.formcenter.model.FormSyncModels.TaskCreateRequest;
import com.yunhai.formcenter.model.FormSyncModels.TaskView;
import com.yunhai.formcenter.service.AnswerReconcileService;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 对账管理面(TASK_3 改动点 7,{@code /api/form/admin/sync/**})。
*
* <p>守卫与 grant 管理同一条:{@code requirePlatformAdmin}(校管理员 ∪ 超管),
* 校级管理员的可见与可操作范围都钉在本校域内(跨域寻址按"任务不存在"回答)。
* 这里<b>没有</b>任何"批量重发全部答卷"的入口——全量只能建对账任务由 Worker 逐差异修复,
* 单答卷才允许直接重放投递({@code replayAuthorized}),且执行前重新过一遍 ACL。</p>
*
* <p>旧 outbox 日志不等于新答卷:{@code /deliveries/failed} 只是投递侧的失败/死信查看,
* 真正的差异修复走 {@code /tasks},两者不互相证明。</p>
*/
@RestController
@RequestMapping("/api/form/admin/sync")
@RequiredArgsConstructor
public class FormSyncAdminController {
private final FormAdminGuard adminGuard;
private final AnswerReconcileService reconcileService;
/** 建对账任务:ANSWER / FORM / ALL 三种范围;超限与范围不符落 FAILED 留痕行并回原因 */
@PostMapping("/tasks")
public List<TaskView> create(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody TaskCreateRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.createManualTasks(user, domain, body);
}
/** 任务列表(进度与状态) */
@GetMapping("/tasks")
public Page<TaskView> list(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam(required = false) String consumerAppKey,
@RequestParam(required = false) Long consumerSchoolId,
@RequestParam(required = false) String state,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.taskPage(domain, consumerAppKey, consumerSchoolId, state, page, size);
}
/** 任务详情(含最近一次回报摘要) */
@GetMapping("/tasks/{taskId}")
public TaskView detail(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("taskId") String taskId) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.taskDetail(domain, taskId);
}
/** 逐 attempt 的结果与失败明细(每条都能定位 consumer/task/range/answer) */
@GetMapping("/tasks/{taskId}/results")
public List<ResultView> results(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("taskId") String taskId) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.results(domain, taskId);
}
/** 取消任务:剩余投递与修复一律停(幂等,终态行不会被改写) */
@PostMapping("/tasks/{taskId}/cancel")
public TaskView cancel(BoaUser user,
@RequestParam(required = false) Long schId,
@PathVariable("taskId") String taskId) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.cancel(domain, taskId, user);
}
/** 单答卷重放(先重新 ACL 再执行;历史 revision 的明文快照不可重建,按稳定码回结论) */
@PostMapping("/replay")
public DeliveryStateView replay(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody ReplayRequest body) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.replayAnswer(user, domain,
body == null ? null : body.getConsumerAppKey(),
body == null ? null : body.getConsumerSchoolId(),
body == null ? null : body.getAnswerUuid(),
body == null ? null : body.getExpectedRevision(),
body == null ? null : body.getOperationId());
}
/** 投递失败/死信分页(TASK_2 failedPage 的转发,不给它另拼一套查询) */
@GetMapping("/deliveries/failed")
public FailedDeliveryPage failedDeliveries(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam String consumerAppKey,
@RequestParam Long consumerSchoolId,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size) {
Long domain = adminGuard.requirePlatformAdmin(user, schId);
return reconcileService.failedDeliveries(domain, consumerAppKey, consumerSchoolId, page, size);
}
}
@@ -0,0 +1,99 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerDeliveryModels.HostedFormView;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.AnswerService;
import com.yunhai.formcenter.service.FormAccessPolicy;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.formcenter.service.FormDictService;
import com.yunhai.formcenter.service.SubmissionContextService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* hosted 填报面(answer-delivery-v2 §6.3,TASK_2 A.3):被业务应用嵌入时的取址与提交。
*
* <p>与中心自用填报页的三点不同,都在"钉版"这件事上:</p>
* <ul>
* <li><b>按上下文令牌取 schema</b>,坐标是签发时锁定的 {@code formUuid+formVersion},
* <b>永不回落到当前发布版</b>——升版窗口里在途的办件必须按当时那一版填,
* 否则收上来的值与办件绑定的坐标就不是同一张表;</li>
* <li><b>身份仍由 CAS 取</b>:令牌只说"这次填报属于哪个意图",
* {@code schId/submitterId} 一律会话推导,且与令牌断言的人/校逐字比对(错人错校即拒);</li>
* <li><b>首批只允许登录本人</b>:匿名与普通提交仍是 FC 自用的两条既有通道,
* 匿名答卷进不了这条链({@code submitterId} 对不上哨兵 0,见
* {@code SubmissionContextService#redeem})。</li>
* </ul>
*
* <p>提交走 {@code POST /api/form/hosted/submit},请求体与 {@code /api/form/submit} 同形
* (多带一个 {@code contextToken});管线仍是 {@code AnswerService} 那一条,
* hosted 只是"多一道令牌复核 + 原子关联意图",不开第二份写库实现。</p>
*/
@RestController
@RequestMapping("/api/form/hosted")
@RequiredArgsConstructor
public class HostedFillController {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SubmissionContextService contextService;
private final AnswerService answerService;
private final FormDefService defService;
private final FormDictService dictService;
/**
* 上下文钉住版本的 schema 与表单要点(令牌过期/错人/错校都拿不到,按上下文无效拒)。
*/
@GetMapping("/schema")
public HostedFormView schema(BoaUser user, @RequestParam("contextToken") String contextToken) {
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,无法打开托管填报页");
SubmissionContext ctx = contextService.redeem(contextToken, user, schId);
FormDefService.Published pinned = defService.resolvePublishedByUuid(schId,
ctx.getFormUuid(), ctx.getFormVersion());
Form form = pinned.form();
HostedFormView view = new HostedFormView();
view.setFormUuid(form.getUuid());
view.setAppCode(form.getAppCode());
view.setCode(form.getCode());
view.setName(form.getName());
view.setVersion(pinned.versionRow().getVersion());
view.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
view.setFillLimit(form.getFillLimit());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
view.setDeadlineReached(FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now()));
view.setGroups(AnswerReadService.groupsNode(
dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson())));
view.setExpiresAt(ctx.getExpiresAt() == null ? null : TS.format(ctx.getExpiresAt()));
view.setEmbedOrigin(ctx.getEmbedOrigin());
return view;
}
/**
* hosted 提交(同一件事的两个入口形状,实现只有一条):请求体必须带 {@code contextToken},
* 由 {@code AnswerService} 在答卷事务内兑换、校验坐标并原子关联意图。
*/
@PostMapping("/submit")
public AnswerModels.SubmitView submit(BoaUser user, @RequestBody AnswerModels.SubmitRequest body) {
if (body == null || body.getContextToken() == null || body.getContextToken().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"hosted 提交必须带 contextToken(托管填报入口不接收无上下文的提交)");
}
return answerService.submit(user, body);
}
}
@@ -0,0 +1,73 @@
package com.yunhai.formcenter.controller.oapi;
import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReport;
import com.yunhai.formcenter.model.AnswerDeliveryModels.AppliedReportView;
import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantSnapshotView;
import com.yunhai.formcenter.model.AnswerDeliveryModels.GrantVerifyRequest;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.service.AnswerDeliveryService;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 投递结果回执与授权核验端点(answer-delivery-v2 §5/§7,TASK_2 B.6 + 撤权边界)。
*
* <p>两条都是<b>签名 HTTP</b>,与 MQ 确认是两回事:</p>
* <ul>
* <li>{@code POST /oapi/form/answer-delivery/receipt}——消费方业务落库后的结果。
* broker 的 confirm 只证明总线收下,绝不能代替它(B.4/B.6);{@code consumerAppKey}
* 以<b>验签身份</b>为准,请求体里自报的值不一致即拒;</li>
* <li>{@code POST /oapi/form/answer-delivery/grants/verify}——轻量授权核验:按 formUuid 批量
* 回 {@code subscribeAllowed + grantVersion},<b>不回查任何答案值文档</b>。消费端带 TTL 缓存它,
* 缓存命中期间撤权的窗口由这条接口收敛;无法确认时消费端必须暂缓写入(v2 §5)。
* 判据仍然是 TASK_1 的 {@code requireSubscribe},这里不拼第二套 ACL。</li>
* </ul>
*
* <p>路径在 {@code /oapi/*} 下,由既有 {@code RequireAppSignFilter} 强制应用签名。</p>
*/
@RestController
@RequestMapping("/oapi/form/answer-delivery")
@RequiredArgsConstructor
public class AnswerDeliveryReceiptController {
private final AnswerDeliveryService deliveryService;
private final ConsumerPrincipalResolver callerResolver;
/** 消费方应用结果回执(幂等:同 eventId+同结论重复上报返回原结论) */
@PostMapping("/receipt")
public AppliedReportView receipt(HttpServletRequest request, @RequestBody AppliedReport body) {
ConsumerPrincipal caller = caller(request);
if (body != null && body.getConsumerAppKey() != null
&& !body.getConsumerAppKey().trim().equals(caller.getAppKey())) {
// 自报消费方与验签身份不符:这不是"参数不对",是冒充,按不匹配拒(回执本身不做存在性区分)
throw new BizException(ErrorCode.FORBIDDEN, "回执申报的 consumerAppKey 与已验签身份不符");
}
if (body != null) {
body.setConsumerAppKey(caller.getAppKey());
}
return deliveryService.recordApplied(body);
}
/** 批量授权核验(每条事件不回查值文档;有 TTL 缓存的消费端用它判"这封还作不作数") */
@PostMapping("/grants/verify")
public List<GrantSnapshotView> grants(HttpServletRequest request, @RequestBody GrantVerifyRequest body) {
return deliveryService.grantSnapshot(caller(request),
body == null ? null : body.getConsumerSchoolId(),
body == null ? null : body.getFormUuids());
}
private ConsumerPrincipal caller(HttpServletRequest request) {
Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
}
}
@@ -0,0 +1,78 @@
package com.yunhai.formcenter.controller.oapi;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.model.FormSyncModels.ManifestPageView;
import com.yunhai.formcenter.model.FormSyncModels.ResultAck;
import com.yunhai.formcenter.model.FormSyncModels.ResultReport;
import com.yunhai.formcenter.model.FormSyncModels.TaskAnswerReply;
import com.yunhai.formcenter.service.AnswerReconcileService;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.platform.appauth.AppAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* 对账任务的消费端面(TASK_3 改动点 3,全部签名 HTTP)。
*
* <p>三条端点<b>成对</b>于 svc-hall 侧的 Worker:清单分页、任务快照答复、结果回报。
* 与 {@code OapiFormController} 隔离——那边是"按游标增量拉答卷"的数据面,
* 这一边是"某一轮对账的取证与回报",鉴权语境完全不同(这里必须核任务坐标与最新 grant)。</p>
*
* <p>身份唯一来源仍是平台验签后写在 {@code yh.app.auth} 上的 appKey;
* 请求里申报的任何 consumerAppKey 都不采信,冒充任务目标即 {@code 403}。
* 每次读取都过一遍 {@code AnswerReconcileService} 的核验:任务存在、身份等于目标、
* 未取消、最新 grant 仍生效且纪元未进位——不因"建任务时有权"永久放行。</p>
*/
@RestController
@RequestMapping("/oapi/form-sync/tasks")
@RequiredArgsConstructor
public class FormSyncTaskController {
private final AnswerReconcileService reconcileService;
private final ConsumerPrincipalResolver callerResolver;
/** 清单分页:游标=上一页返回的 nextCursor(answerUuid),首次传空;结束只看 completed */
@GetMapping("/{taskId}/manifest")
public ManifestPageView manifest(HttpServletRequest request,
@PathVariable("taskId") String taskId,
@RequestParam(value = "cursor", required = false) String cursor,
@RequestParam(value = "size", required = false) Integer size) {
return reconcileService.manifestPage(caller(request), taskId, cursor, size);
}
/**
* 任务快照答复:修复入口的唯一权威来源。
*
* <p>{@code expectedRevision} 只在"中心清单里没有这份答卷"时用于把缺席证明钉在消费端扫描到的
* 那一版上(消费端并发护栏);存在性结论与快照内容都来自中心当前值。</p>
*/
@GetMapping("/{taskId}/answers/{answerUuid}")
public TaskAnswerReply answer(HttpServletRequest request,
@PathVariable("taskId") String taskId,
@PathVariable("answerUuid") String answerUuid,
@RequestParam(value = "expectedRevision", required = false)
Long expectedRevision) {
return reconcileService.taskAnswer(caller(request), taskId, answerUuid, expectedRevision);
}
/** 结果回报:{@code (taskId, attempt)} 幂等,旧 attempt 不倒退新 attempt */
@PostMapping("/{taskId}/result")
public ResultAck result(HttpServletRequest request,
@PathVariable("taskId") String taskId,
@RequestBody ResultReport body) {
return reconcileService.submitResult(caller(request), taskId, body);
}
private ConsumerPrincipal caller(HttpServletRequest request) {
Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
}
}
@@ -1,22 +1,42 @@
package com.yunhai.formcenter.controller.oapi; package com.yunhai.formcenter.controller.oapi;
import com.yunhai.formcenter.model.FormAccessModels.CatalogPage;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.model.OapiModels; import com.yunhai.formcenter.model.OapiModels;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.OapiFormService; import com.yunhai.formcenter.service.OapiFormService;
import com.yunhai.platform.appauth.AppAuthFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/** /**
* 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的 * 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的
* {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。 * {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。
* *
* <pre> * <pre>
* GET /oapi/form/schema?schId=&code=[&appCode=] → data=内联 options + valueSchema 的机读 schema(契约 §4) * GET /oapi/form/forms?schId=&amp;appCode=&amp;keyword=&amp;page=&amp;pageSize= → v3 目录(已发布 ∩ 可读,先过滤再分页)
* GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9) * GET /oapi/form/schema?schId=&amp;code=[&amp;appCode=] → 现形态:内联 options + valueSchema 的机读 schema(契约 §4)
* GET /oapi/form/schema?schId=&amp;uuid= → T11 新形态:该表单<b>当前已发布版</b>
* GET /oapi/form/schema?schId=&amp;uuid=&amp;version= → T11 新形态:精确版本行(快照核对/重放)
* GET /oapi/form/answer?schId=&amp;answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9)
* GET /oapi/form/answers?schId=&amp;form_uuid=&amp;游标 → v2 增量拉取(契约 v2 §6.1,T17,见方法注释)
* </pre> * </pre>
* *
* <p><b>签名只给身份,不给权限</b>(v3 §1/§3):本控制器先取平台验签后落在请求属性
* {@code yh.app.auth} 上的 appKey({@link ConsumerPrincipalResolver}),再交给服务层过
* {@code FormConsumerAccessService.requireRead}。查询参数 {@code appCode} 与 {@code schId} 一样
* 只是资源筛选/租户域,任何"自报应用"的写法都不构成授权。</p>
*
* <p>uuid 两形态的用途=给 flow-center 在<b>流程定义发布期</b>拉契约快照嵌进 def 版本行
* (《ysch-flow单一接入件与中心托管体验-实施方案v2-20261019.md》§4.4),运行期中心零依赖。
* <b>uuid 与 code 同传时 uuid 优先</b>(code/appCode 被忽略):uuid 是权威坐标、code 是解析入口,
* 两者同传说明调用方手里是存下来的 uuid,按它取才是它要的那张表。响应形状与 code 形态完全一致。</p>
*
* <p>信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调 * <p>信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调
* (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。</p> * (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。</p>
* *
@@ -30,19 +50,72 @@ import org.springframework.web.bind.annotation.RestController;
public class OapiFormController { public class OapiFormController {
private final OapiFormService formService; private final OapiFormService formService;
private final ConsumerPrincipalResolver callerResolver;
/** 机读 schema(内联 options + valueSchema) */ /**
* 机读 schema(内联 options + valueSchema);寻址支持 code 与 uuid 两种形态,
* <b>同传时 uuid 优先</b>(见类头),{@code version} 只在 uuid 形态有意义=精确取版本行。
*/
@GetMapping("/schema") @GetMapping("/schema")
public OapiModels.SchemaView schema(@RequestParam("schId") Long schId, public OapiModels.SchemaView schema(HttpServletRequest request,
@RequestParam("code") String code, @RequestParam("schId") Long schId,
@RequestParam(value = "appCode", required = false) String appCode) { @RequestParam(value = "code", required = false) String code,
return formService.schema(schId, appCode, 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(caller(request), schId, appCode, code, uuid, version);
} }
/** 单条答卷(钉住版本 schema 引用 + 值文档) */ /** 单条答卷(钉住版本 schema 引用 + 值文档) */
@GetMapping("/answer") @GetMapping("/answer")
public OapiModels.AnswerView answer(@RequestParam("schId") Long schId, public OapiModels.AnswerView answer(HttpServletRequest request,
@RequestParam("schId") Long schId,
@RequestParam("answerId") String answerId) { @RequestParam("answerId") String answerId) {
return formService.answer(schId, answerId); return formService.answer(caller(request), schId, answerId);
}
/**
* v2 增量拉取(契约 §6.1/C9,T17)——<b>事实源</b>,MQ 事件只是降延迟。
*
* <pre>
* GET /oapi/form/answers?schId=&amp;form_uuid=&amp;after=answer_uuid[&amp;limit=] 冻结表单:append-only + 墓碑
* GET /oapi/form/answers?schId=&amp;form_uuid=&amp;updated_since=ISO8601[|answer_uuid] 非冻结:updated 窗口
* </pre>
*
* <p>两种游标按表单的冻结状态二分、不接受混用(拒绝文案点明这张表要哪种),{@code limit} 默认 500
* 上限 2000;响应 {@code {rows,nextCursor}},行字段是闭集且<b>不含库内 id</b>(V53)。
* {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看,
* v3 起还要另过 read 授权)。</p>
*/
@GetMapping("/answers")
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(caller(request), schId, formUuid, after, updatedSince, limit);
}
/**
* 表单目录(v3 §3):给消费方的选择器用,返回 {@code {items,total}}。
*
* <p>服务端过滤链=已发布 ∩ 未软删 ∩ 本校或平台级 ∩ 调用方可读,<b>过滤完才分页</b>
* (先分页后过滤会让 {@code total} 与页数随授权抖动)。{@code pageSize} 上限 100。</p>
*/
@GetMapping("/forms")
public CatalogPage forms(HttpServletRequest request,
@RequestParam("schId") Long schId,
@RequestParam(value = "appCode", required = false) String appCode,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return formService.catalog(caller(request), schId, appCode, keyword, page, pageSize);
}
/** 已验签的消费应用身份;属性缺失=验签墙没生效,解析器按未授权拒(fail-closed) */
private ConsumerPrincipal caller(HttpServletRequest request) {
Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
} }
} }
@@ -0,0 +1,56 @@
package com.yunhai.formcenter.controller.oapi;
import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextIssueRequest;
import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextIssueView;
import com.yunhai.formcenter.model.AnswerDeliveryModels.ContextReceiptView;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.service.ConsumerPrincipalResolver;
import com.yunhai.formcenter.service.SubmissionContextService;
import com.yunhai.platform.appauth.AppAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* 可信提交上下文端点(answer-delivery-v2 §6,TASK_2 A.2/A.4)。
*
* <p><b>独立控制器、独立 DTO</b>:任务书明令不改 TASK_1 的 {@code OapiFormController/OapiModels},
* 上下文与收据这条链全部落在这里({@link com.yunhai.formcenter.model.AnswerDeliveryModels})。
* 路径仍在 {@code /oapi/*} 之下,由既有 {@code RequireAppSignFilter} 强制应用签名,
* 调用方身份只取验签后的 {@code yh.app.auth}——请求体里没有任何"自报应用"的字段。</p>
*
* <pre>
* POST /oapi/form/submission-context → 签发/复用上下文(同意图不新建第二条业务意图)
* GET /oapi/form/submission-context/{intentId}/receipt → 收据(只向所属消费应用返回)
* </pre>
*/
@RestController
@RequestMapping("/oapi/form/submission-context")
@RequiredArgsConstructor
public class SubmissionContextController {
private final SubmissionContextService contextService;
private final ConsumerPrincipalResolver callerResolver;
@PostMapping
public ContextIssueView issue(HttpServletRequest request, @RequestBody ContextIssueRequest body) {
return contextService.issue(caller(request), body);
}
@GetMapping("/{intentId}/receipt")
public ContextReceiptView receipt(HttpServletRequest request, @PathVariable("intentId") String intentId) {
return contextService.receipt(caller(request), intentId);
}
/** 已验签的消费应用身份;属性缺失=验签墙没生效,解析器按未授权拒(fail-closed,同 v3 §1) */
private ConsumerPrincipal caller(HttpServletRequest request) {
Object verified = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
return callerResolver.resolveSignedCaller(verified == null ? null : String.valueOf(verified));
}
}
@@ -1,6 +1,8 @@
/** /**
* 对外签名面(/oapi/**,appKey 三件套验签,见 application.yml 的 yh.app-auth.server.enforce-patterns)。 * 对外签名面(/oapi/**,appKey 三件套验签,见 application.yml 的 yh.app-auth.server.enforce-patterns)。
* T4 落地契约 §9 的两个读端点:{@code GET /oapi/form/schema}(内联 options + valueSchema)与 * T4 落地契约 §9 的两个读端点:{@code GET /oapi/form/schema}(内联 options + valueSchema)与
* {@code GET /oapi/form/answer}。一期<b>无 oapi 代提交</b>(方案 §2.3 边界,答卷身份一律会话推导)。 * {@code GET /oapi/form/answer}。T17 加第三个:{@code GET /oapi/form/answers}(契约 v2 §6.1 增量拉取,
* <b>它是消费方的事实源</b>,MQ 事件只降延迟、不含值文档)。一期<b>无 oapi 代提交</b>
* (方案 §2.3 边界,答卷身份一律会话推导)。
*/ */
package com.yunhai.formcenter.controller.oapi; package com.yunhai.formcenter.controller.oapi;
@@ -18,8 +18,10 @@ import javax.persistence.UniqueConstraint;
* (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与 * (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与
* 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20), * 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
* 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。</li> * 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、 * <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交(匿名=哨兵 0)、
* {@code sch_id}=会话校,<b>全部会话推导不可外部传入</b>(契约 §5、V17)。</li> * {@code subject_id}=关于谁(匿名恒 NULL)、{@code sch_id}=租户域(登录答卷恒=会话校;
* <b>匿名答卷=表单归属校</b>,契约 v2 §3.2/C8 取代原"恒=会话校"单句),
* <b>全部会话或表单推导,不可外部传入</b>(契约 §5、V17/V57)。</li>
* <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1), * <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1),
* 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。</li> * 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。</li>
* </ul> * </ul>
@@ -33,17 +35,27 @@ import javax.persistence.UniqueConstraint;
@Table(name = "answer", @Table(name = "answer",
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint(name = "uk_answer_uuid", columnNames = "uuid"), @UniqueConstraint(name = "uk_answer_uuid", columnNames = "uuid"),
@UniqueConstraint(name = "uk_answer_idem", columnNames = "idempotency_key") @UniqueConstraint(name = "uk_answer_idem", columnNames = "idempotency_key"),
// Bug#4:报名号是 count+1 生成的(当日已有号数),count 与 insert 之间有并发窗口,
// 同表单同日并发提交会算出同一个号。号是对外凭证,库级唯一是唯一靠得住的兜底
// (null 不参与唯一判定,未开报名号的表单不受影响);撞键由 AnswerService 重算序号重试。
@UniqueConstraint(name = "uk_answer_no",
columnNames = {"sch_id", "app_code", "code", "answer_no"})
}, },
indexes = { indexes = {
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"), @Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"),
@Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"), @Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"),
@Index(name = "idx_answer_subject", columnList = "subject_id"), @Index(name = "idx_answer_subject", columnList = "subject_id"),
// T10 方案 §4③:SCHOOL 范围(本校提交)的谓词列索引
@Index(name = "idx_answer_submitter_sch", columnList = "submitter_sch_id"),
// 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id) // 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id)
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。 // ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的, // 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
// 不含 app_code 且无法在跨状态时给出 id 序 → 新增本索引,登记问题单。 // 不含 app_code 且无法在跨状态时给出 id 序 → 新增本索引,登记问题单。
@Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id") @Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id"),
// 契约 v2 §6.1:非冻结表单的 updated_since 增量拉取按 (form_uuid, update_time, id) 推进,
// 现状 answer 表无任何 update_time 索引(t3+t10 全查过)——本索引是 T17 拉取端点的前置
@Index(name = "idx_answer_form_utime", columnList = "form_uuid, update_time, id")
}) })
public class Answer extends BaseEntity { public class Answer extends BaseEntity {
@@ -82,6 +94,17 @@ public class Answer extends BaseEntity {
@Column(name = "submitter_id", nullable = false, updatable = false) @Column(name = "submitter_id", nullable = false, updatable = false)
private Integer submitterId; private Integer submitterId;
/**
* 填报人会话学校(T10 方案 §4③,D2=B 的落点):范围值 {@code SCHOOL} 的谓词列,
* 提交时从会话 {@code currentRole.schId} 落值、之后不改。
*
* <p>NULL=未记录(存量行),按"不参与校级范围匹配"处理(fail-closed,方案 §4 存量条款)。
* 它与 {@code sch_id} 同源于会话校(校域不变式:见下方建表注记),本列是把"本校提交"
* 写成显式谓词,而不是靠租户域隐含它。</p>
*/
@Column(name = "submitter_sch_id", updatable = false)
private Long submitterSchId;
/** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */ /** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */
@Column(name = "subject_id") @Column(name = "subject_id")
private Integer subjectId; private Integer subjectId;
@@ -102,7 +125,36 @@ public class Answer extends BaseEntity {
@Column(name = "idempotency_key", nullable = false, length = 64, updatable = false) @Column(name = "idempotency_key", nullable = false, length = 64, updatable = false)
private String idempotencyKey; private String idempotencyKey;
/**
* 匿名提交来源 IP(契约 v2 C8/C12):只由匿名面(T15)写入,登录答卷恒 NULL;
* 不进 {@code values_json}(留痕是采集事实之外的东西,混进值文档就没法比对了)。
*/
@Column(name = "anon_ip", length = 64)
private String anonIp;
/** 匿名提交 User-Agent(同上,列宽 512 够装常规 UA 串;超长由服务层截断) */
@Column(name = "anon_ua", length = 512)
private String anonUa;
/** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */ /** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0; private Integer isDeleted = 0;
/**
* 对外快照修订号(v2 投递契约 §2,TASK_2 B.1):<b>单调递增</b>,与 schema 的
* {@code version}(钉版坐标,{@code updatable=false})是两回事——前者说"这份数据又被改过一次",
* 后者说"它是按哪一版结构填的"。
*
* <p>凡是会改变<b>对外答卷快照</b>的路径都在同一事务里 +1 并写不可变 outbox:
* 创建、本人/管理员值编辑、改业务状态(含批量)、软删(含批量)。刻意不把
* {@code answer_index} 重建、留痕行这类"内部副作用"算作 revision 变更点之外的口径——
* 判据只有一条:<b>消费方看到的字节变了吗</b>。</p>
*
* <p>并发控制=行级 CAS({@code AnswerRepository#compareAndSetRevision},UPDATE 自身拿行锁,
* 抢不到号即 0 行→回滚)+ outbox 的 {@code uk_delivery_revision} 唯一键兜底。
* 没上 JPA {@code @Version}:那会让存量分离实体 {@code save} 路径(并发 IT 与批量导入)
* 突然开始抛 OptimisticLockException,属于跨批次的行为变更,不在本任务授权范围内。</p>
*/
@Column(name = "answer_revision", nullable = false)
private Long answerRevision = 0L;
} }
@@ -0,0 +1,148 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 答卷快照投递行(outbox,answer-delivery-v2 §3,TASK_2 B.1/B.3/B.4)。
*
* <p><b>它是持久性载体,不是发送日志</b>:行必须在答卷<b>同一事务</b>里落库(旧
* {@code FcEventPublisher} 的 afterCommit 只负责"发",进程在提交与发送之间被杀就永久丢事件——
* 契约 v2 那时可以用"拉取即事实源"兜底,v2 快照投递不行,因为它要驱动对端的办件)。
* afterCommit 从此只剩一个作用:唤醒扫描。</p>
*
* <p><b>唯一键 {@code uk_delivery_revision} 是"同一 revision 不能对应两份不同 hash"的库级形态</b>:
* 两个并发写事务都读到 revision=N-1、各自算出 N 时,只有一个能插入成功,另一个整事务回滚
* (答卷与 outbox 同行消失),不存在"两条同版不同内容的投递行"。快照内容因此不可重建历史版本,
* 每行自带 {@code snapshot_json}——{@code intent} 关联块只对单个 consumer 可见,
* 各行的载荷本来就不同,不做跨行共享。</p>
*
* <p>{@code state} 只描述<b>投递</b>,不描述业务:{@code BROKER_ACCEPTED} 只代表总线收下
* (B.4 明令不得写成消费成功),消费方是否真的落库要看 {@code consumer_applied_at} /
* {@code consumer_outcome},那两列只由签名回执端点写。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer_delivery",
uniqueConstraints = {
@UniqueConstraint(name = "uk_delivery_event", columnNames = "event_id"),
@UniqueConstraint(name = "uk_delivery_revision",
columnNames = {"answer_uuid", "answer_revision", "consumer_app_key", "consumer_school_id"})
},
indexes = {
@Index(name = "idx_delivery_scan", columnList = "state, next_retry_at"),
@Index(name = "idx_delivery_consumer", columnList = "consumer_app_key, consumer_school_id, id"),
@Index(name = "idx_delivery_answer", columnList = "answer_uuid, answer_revision")})
public class AnswerDelivery extends BaseEntity {
private static final long serialVersionUID = 1L;
// ===== 投递状态机(state 列)=====
/** 已落库待发送(含 broker 不可用时的退避等待) */
public static final String STATE_PENDING = "PENDING";
/** 总线 confirm 收下(≠ 消费方应用成功) */
public static final String STATE_BROKER_ACCEPTED = "BROKER_ACCEPTED";
/** 发送异常/mandatory 不可路由,退避后仍会重试 */
public static final String STATE_FAILED = "FAILED";
/** 有界重试耗尽,留给人工/对账处置(可查,不再自动重投) */
public static final String STATE_DEAD = "DEAD";
/** 发布前复核发现 grant 已失效/进位:不重放旧授权(v2 §5 撤权边界) */
public static final String STATE_GRANT_REVOKED = "GRANT_REVOKED";
/** 消费方结果:本地已落库 */
public static final String OUTCOME_APPLIED = "APPLIED";
/** 消费方结果:按 revision 判定无需应用(低版本或同版同 hash 重放) */
public static final String OUTCOME_IGNORED = "IGNORED";
/** 消费方结果:同 revision 异 hash,冲突待核验(消费端不覆盖) */
public static final String OUTCOME_CONFLICT = "CONFLICT";
/** 信封事件 id(uuid;broker 侧相关键与消费端幂等键都用它) */
@Column(name = "event_id", nullable = false, length = 36, updatable = false)
private String eventId;
@Column(name = "protocol_version", nullable = false, length = 32, updatable = false)
private String protocolVersion;
/** created / updated / tombstoned */
@Column(name = "event_type", nullable = false, length = 16, updatable = false)
private String eventType;
/** 发布方应用身份(服务端配置,不是消费方申报) */
@Column(name = "source_app_key", length = 64)
private String sourceAppKey;
/** 投递目标:显式消费应用 appKey(<b>不是</b>资源 appCode,B.3) */
@Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
private String consumerAppKey;
@Column(name = "consumer_school_id", nullable = false, updatable = false)
private Long consumerSchoolId;
/** 入队时刻的授权纪元(发送前还要再核一次,B.3) */
@Column(name = "grant_version", nullable = false)
private Integer grantVersion;
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
@Column(name = "form_version", nullable = false, updatable = false)
private Integer formVersion;
@Column(name = "answer_uuid", nullable = false, length = 32, updatable = false)
private String answerUuid;
/** 单调快照修订号(与 answer.answer_revision 同值) */
@Column(name = "answer_revision", nullable = false, updatable = false)
private Long answerRevision;
/** 墓碑标记(软删行的快照照常投递,靠它表达"删除") */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint", updatable = false)
private Integer isDeleted = 0;
/** {@code fc.canon.v1} 下快照(剔除 intent)的 SHA-256 */
@Column(name = "content_hash", nullable = false, length = 64, updatable = false)
private String contentHash;
/** 业务发生时刻(快照内已含 create/updateTime,这一列是信封 occurredAt 的落库形态) */
@Column(name = "occurred_at", nullable = false)
private LocalDateTime occurredAt;
/** 不可变载荷:该 consumer 看到的快照 JSON(含 intent 关联块) */
@Column(name = "snapshot_json", nullable = false, columnDefinition = "longtext")
private String snapshotJson;
@Column(name = "state", nullable = false, length = 16)
private String state = STATE_PENDING;
@Column(name = "retry_count", nullable = false)
private Integer retryCount = 0;
/** 退避到点才再扫(broker 故障不许热循环) */
@Column(name = "next_retry_at")
private LocalDateTime nextRetryAt;
@Column(name = "broker_accepted_at")
private LocalDateTime brokerAcceptedAt;
@Column(name = "consumer_applied_at")
private LocalDateTime consumerAppliedAt;
/** APPLIED / IGNORED / CONFLICT(只有签名回执端点写) */
@Column(name = "consumer_outcome", length = 16)
private String consumerOutcome;
/** 最近一次授权重投的操作幂等键({@code replayAuthorized} 写;同键重复调用不再刷新退避) */
@Column(name = "last_replay_operation_id", length = 64)
private String lastReplayOperationId;
@Column(name = "last_error", length = 500)
private String lastError;
}
@@ -0,0 +1,72 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 任务 manifest 行(TASK_3,answer-reconcile-v1 §3)。
*
* <p><b>本任务里唯一充当"不可变清单"的东西</b>:在一致读事务内物化
* {@code (answerUuid, revision, contentHash, isDeleted)} 四元摘要(含墓碑),
* 之后所有分页只读这张表,不再回 {@code answer} 表。清单因此不会被物化之后发生的写入改动,
* "这一页空了"与"这个集合到底了"是两件事——后者只能由 {@code manifestComplete} 表达。</p>
*
* <p>键序按 {@code answer_uuid} 升序(32 位小写十六进制),与消费端本地 keyset 扫描同序,
* 两侧能用同一个游标语义锁步推进。哈希取 {@code AnswerSnapshotService} 的
* {@code fc.canon.v1} 口径,与投递行同一条规范化实现——本类不复制任何规范化逻辑。</p>
*
* <p>{@code formVersion} 取物化时该答卷钉住的 schema 版本(不是表单当前版本,也不从旧清单猜):
* 修复信封的 {@code formVersion} 必须由中心已核验的坐标给出,这一列就是那个坐标的留痕。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer_reconcile_manifest_row",
uniqueConstraints = @UniqueConstraint(name = "uk_manifest_row",
columnNames = {"task_id", "answer_uuid"}),
indexes = {
@Index(name = "idx_manifest_page", columnList = "task_id, answer_uuid"),
@Index(name = "idx_manifest_seq", columnList = "task_id, seq_no")})
public class AnswerReconcileManifestRow extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 逻辑引用 answer_reconcile_task.id(不建物理外键:任务行是审计资产,清单要能在其后继续存在) */
@Column(name = "task_id", nullable = false, updatable = false)
private Long taskId;
@Column(name = "task_uuid", nullable = false, length = 36, updatable = false)
private String taskUuid;
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
/** 物化时核验过的发布版本(修复信封坐标) */
@Column(name = "form_version", nullable = false, updatable = false)
private Integer formVersion;
@Column(name = "answer_uuid", nullable = false, length = 32, updatable = false)
private String answerUuid;
/** 物化时刻的对外修订号(不是库内 id、不是页码) */
@Column(name = "revision", nullable = false, updatable = false)
private Long revision;
/** fc.canon.v1 摘要(64 位十六进制) */
@Column(name = "content_hash", nullable = false, length = 64, updatable = false)
private String contentHash;
/** 墓碑也进清单:缺席与"存在但已删"是两个不同结论 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint", updatable = false)
private Integer isDeleted;
/** 物化写入序号(审计用;分页游标是 answer_uuid,不是它) */
@Column(name = "seq_no", nullable = false, updatable = false)
private Integer seqNo;
}
@@ -0,0 +1,122 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 对账结果回报(TASK_3,answer-reconcile-v1 §5):消费端一次执行的<b>可追踪事实</b>。
*
* <p>唯一键 {@code (task_id, attempt)} 是幂等锚点:同一 attempt 重复上报直接吸收,
* 比已记录 attempt 更旧的回报只留档、不推进任务状态("不让旧 attempt 回报倒退新 attempt")。
* 一行=一个 attempt,重派产生的新 attempt 是另一行,历史不被覆盖。</p>
*
* <p>计数都是<b>有上限</b>的摘要:明细只留在 {@code errors_json}(截断到此列长度),
* 答卷的 values 一律不进这里,也不进日志(§8 敏感数据纪律)。</p>
*
* <p>状态语义与任务终态一致:{@code COMPLETED}=两侧集合与摘要在完整扫描下无未决差异,
* {@code PARTIAL}=扫描或修复有未决项(差异仍在,但任务本身跑完了),
* {@code FAILED}/{@code CANCELLED}=执行中断。两者不能混为一谈。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer_reconcile_result",
uniqueConstraints = @UniqueConstraint(name = "uk_result_attempt",
columnNames = {"task_id", "attempt"}),
indexes = {
@Index(name = "idx_result_task", columnList = "task_id, attempt"),
@Index(name = "idx_result_consumer", columnList = "consumer_app_key, consumer_school_id, id")})
public class AnswerReconcileResult extends BaseEntity {
private static final long serialVersionUID = 1L;
@Column(name = "task_id", nullable = false, updatable = false)
private Long taskId;
@Column(name = "task_uuid", nullable = false, length = 36, updatable = false)
private String taskUuid;
@Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
private String consumerAppKey;
@Column(name = "consumer_school_id", nullable = false, updatable = false)
private Long consumerSchoolId;
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
/** 本次回报对应的派发尝试(指令里的 attempt;0=首派) */
@Column(name = "attempt", nullable = false, updatable = false)
private Integer attempt;
/** COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报) */
@Column(name = "state", nullable = false, length = 16, updatable = false)
private String state;
/** 消费端本地扫描是否整轮完成(多余行判定的前提之一) */
@Column(name = "local_scan_complete", nullable = false, columnDefinition = "tinyint", updatable = false)
private Integer localScanComplete;
/** 消费端实际读到的中心清单结束标记(必须与中心一致,否则本轮结论不成立) */
@Column(name = "manifest_complete", nullable = false, columnDefinition = "tinyint", updatable = false)
private Integer manifestComplete;
@Column(name = "scanned_rows", nullable = false, updatable = false)
private Integer scannedRows;
@Column(name = "pages_processed", nullable = false, updatable = false)
private Integer pagesProcessed;
/** 缺失修复(本地无行,按中心当前值建未关联投影) */
@Column(name = "missing_repaired", nullable = false, updatable = false)
private Integer missingRepaired;
/** 陈旧修复(本地 revision 更低) */
@Column(name = "stale_repaired", nullable = false, updatable = false)
private Integer staleRepaired;
/** 同 revision 异 hash:记录后按中心当前值修复 */
@Column(name = "hash_conflicts", nullable = false, updatable = false)
private Integer hashConflicts;
/** 墓碑修复(同一权威修复原语,保留本地业务扩展) */
@Column(name = "tombstones_repaired", nullable = false, updatable = false)
private Integer tombstonesRepaired;
/** 本地 revision 更高:只复核、不降级覆盖 */
@Column(name = "local_ahead", nullable = false, updatable = false)
private Integer localAhead;
/** 授权核验不可达/撤权/纪元不等而暂停的条目 */
@Column(name = "deferred", nullable = false, updatable = false)
private Integer deferred;
/** 只报告的多余行(含期间新增、createTime 未知、范围不明) */
@Column(name = "extra_reported", nullable = false, updatable = false)
private Integer extraReported;
/** 经 FC 签名缺席证明实际退役的行数(未过证明即不增) */
@Column(name = "extra_retired", nullable = false, updatable = false)
private Integer extraRetired;
/** 错误条目总数(可能被截断,明细数以 errors_json 里为准) */
@Column(name = "error_count", nullable = false, updatable = false)
private Integer errorCount;
@Column(name = "errors_json", nullable = false, columnDefinition = "mediumtext", updatable = false)
private String errorsJson;
/** 消费端自报的执行起点(断线重启续跑时用于核对检查点) */
@Column(name = "resumed", nullable = false, columnDefinition = "tinyint", updatable = false)
private Integer resumed;
@Column(name = "reported_at", nullable = false, updatable = false)
private LocalDateTime reportedAt;
}
@@ -0,0 +1,185 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 差异对账任务(TASK_3,answer-reconcile-v1 §2)。
*
* <p><b>它是任务,不是投递行、不是答卷、也不是 {@code answer_op_log}</b>:一行=一个
* {@code (消费应用, 消费租户域, 表单)} 坐标上的一次对账企图。投递行({@link AnswerDelivery})
* 描述"这份快照有没有发出去",本行描述"这两边的集合差在哪、修复回报过没有",两者不能互相代替。</p>
*
* <p>任务坐标只到表单,不再宽到"全部表单":<b>全量=按生效订阅逐张表 fission 成多行任务</b>
* ({@code idempotencyKey} 里的日期+订阅键保证同一天同一订阅只建一次)。这样每一行的
* manifest 都能用单一 {@code answerUuid} 游标分页,与消费端本地 keyset 扫描同序,
* 两侧可以锁步比较——把"跨表单复合游标"这种一定会写错的东西从设计里删掉。</p>
*
* <p>派发纪律:{@code leaseUntil} 是本应用持有派发权的期限,超期由扫描按 {@code attempt+1}
* 有限退避重派;{@code dispatchedAt} 只说明"对端 2xx 收下了指令",
* <b>绝不等于</b>对端做完——终态只能由 {@link AnswerReconcileResult} 的回报推进。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "answer_reconcile_task",
uniqueConstraints = {
@UniqueConstraint(name = "uk_reconcile_task_uuid", columnNames = "task_uuid"),
@UniqueConstraint(name = "uk_reconcile_task_idem", columnNames = "idempotency_key")
},
indexes = {
@Index(name = "idx_reconcile_dispatch", columnList = "state, next_dispatch_at"),
@Index(name = "idx_reconcile_consumer", columnList = "consumer_app_key, consumer_school_id, id"),
@Index(name = "idx_reconcile_coord", columnList = "consumer_app_key, consumer_school_id, form_uuid")})
public class AnswerReconcileTask extends BaseEntity {
private static final long serialVersionUID = 1L;
// ===== 任务状态(验收状态闭集,§4)=====
public static final String STATE_QUEUED = "QUEUED";
public static final String STATE_DISPATCHED = "DISPATCHED";
public static final String STATE_RUNNING = "RUNNING";
public static final String STATE_COMPLETED = "COMPLETED";
public static final String STATE_PARTIAL = "PARTIAL";
public static final String STATE_FAILED = "FAILED";
public static final String STATE_CANCELLED = "CANCELLED";
/** 终态:不再被派发扫描领取,也不接受进度回退 */
public static boolean isTerminal(String state) {
return STATE_COMPLETED.equals(state) || STATE_PARTIAL.equals(state)
|| STATE_FAILED.equals(state) || STATE_CANCELLED.equals(state);
}
/** 活跃态:同一坐标上只允许一个(单并发),且这些状态要受租约与取消约束 */
public static boolean isActive(String state) {
return STATE_QUEUED.equals(state) || STATE_DISPATCHED.equals(state) || STATE_RUNNING.equals(state);
}
// ===== 触发方式 =====
public static final String TRIGGER_SCHEDULE = "SCHEDULE";
public static final String TRIGGER_MANUAL = "MANUAL";
// ===== 稳定错误码(每条错误都要能定位 consumer/task/range/answer,§8)=====
public static final String ERR_GRANT_REVOKED = "GRANT_REVOKED";
public static final String ERR_MANIFEST_INCOMPLETE = "MANIFEST_INCOMPLETE";
public static final String ERR_RESOURCE_LIMIT = "RESOURCE_LIMIT";
public static final String ERR_TARGET_NOT_REGISTERED = "TARGET_NOT_REGISTERED";
public static final String ERR_DISPATCH_FAILED = "DISPATCH_FAILED";
public static final String ERR_LEASE_EXPIRED = "LEASE_EXPIRED";
public static final String ERR_CANCELLED = "CANCELLED";
public static final String ERR_TASK_NOT_FOUND = "TASK_NOT_FOUND";
public static final String ERR_SCOPE_MISMATCH = "SCOPE_MISMATCH";
/** 任务对外身份(服务端 UUID,≤36 字符;URL 与指令里都用它,不用库内 id) */
@Column(name = "task_uuid", nullable = false, length = 36, updatable = false)
private String taskUuid;
@Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
private String consumerAppKey;
/** 消费租户域:与投递行同口径,也就是答卷的归属校(§1 坐标定义) */
@Column(name = "consumer_school_id", nullable = false, updatable = false)
private Long consumerSchoolId;
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
/** 物化时核验过的发布版本;缺席答复与修复信封的 formVersion 都取这一个值 */
@Column(name = "form_version", nullable = false)
private Integer formVersion;
/** SCHEDULE / MANUAL(列名避开 MySQL 保留字 TRIGGER) */
@Column(name = "trigger_type", nullable = false, length = 16, updatable = false)
private String triggerType;
@Column(name = "operator", length = 64, updatable = false)
private String operator;
/** 日期 + 订阅 + 范围唯一键:调度重复跑不出第二行任务 */
@Column(name = "idempotency_key", nullable = false, length = 128, updatable = false)
private String idempotencyKey;
@Column(name = "state", nullable = false, length = 16)
private String state = STATE_QUEUED;
/** manifest 物化状态:派发只在 COMPLETE 后进行 */
@Column(name = "manifest_state", nullable = false, length = 16)
private String manifestState = MANIFEST_PENDING;
public static final String MANIFEST_PENDING = "PENDING";
public static final String MANIFEST_MATERIALIZING = "MATERIALIZING";
public static final String MANIFEST_COMPLETE = "COMPLETE";
public static final String MANIFEST_FAILED = "FAILED";
/** 结束标记:只有它为 1,"清单里没有"才可能是权威缺席 */
@Column(name = "manifest_complete", nullable = false, columnDefinition = "tinyint")
private Integer manifestComplete = 0;
@Column(name = "total_rows", nullable = false)
private Integer totalRows = 0;
@Column(name = "total_pages", nullable = false)
private Integer totalPages = 0;
@Column(name = "materialized_rows", nullable = false)
private Integer materializedRows = 0;
@Column(name = "materialized_pages", nullable = false)
private Integer materializedPages = 0;
/** 物化检查点:已写到的最后一个 answerUuid(进程重启从这里续) */
@Column(name = "checkpoint_cursor", length = 32)
private String checkpointCursor;
/**
* 任务起点:一致读事务的开始时刻。消费端据此把"本地 createTime 晚于此刻"的行
* 判为期间新增——这类行永远不许被当多余行退役。
*/
@Column(name = "window_start", nullable = false, updatable = false)
private LocalDateTime windowStart;
/** manifest 物化完成时刻(一致读的终点,给运维看窗口长度) */
@Column(name = "window_end")
private LocalDateTime windowEnd;
/** 物化时刻的授权纪元;每轮读取再核一次,进位即视为撤权 */
@Column(name = "grant_version", nullable = false)
private Integer grantVersion = 1;
/** 派发尝试次数:首次为 0,租约超期或派发失败后 +1 */
@Column(name = "attempt", nullable = false)
private Integer attempt = 0;
@Column(name = "lease_until")
private LocalDateTime leaseUntil;
@Column(name = "next_dispatch_at")
private LocalDateTime nextDispatchAt;
@Column(name = "dispatched_at")
private LocalDateTime dispatchedAt;
@Column(name = "finished_at")
private LocalDateTime finishedAt;
/** 取消请求:管理面置 1 后,后续清单/快照读取一律以取消口径答复,消费端据此停止剩余修复 */
@Column(name = "cancel_requested", nullable = false, columnDefinition = "tinyint")
private Integer cancelRequested = 0;
@Column(name = "error_code", length = 32)
private String errorCode;
@Column(name = "error_detail", length = 500)
private String errorDetail;
public boolean isCancelled() {
return Integer.valueOf(1).equals(cancelRequested) || STATE_CANCELLED.equals(state);
}
}
@@ -0,0 +1,47 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 快应用注册表(契约 v2 §5.2/§17.2,T16)。
*
* <p><b>四个业务列,零角色列</b>(D14/V59 红线):这张表只回答"有哪些应用、叫什么名字、谁建的、删没删"。
* "谁能进哪个应用"一律不在这张表里——它由 {@code DataScopeService} 按会话在该应用的表单上现算 ALL/SELF 档。
* 一旦这里出现 {@code role}/{@code admin}/{@code user_id} 之类的列,就等于把 v2 出清掉的授权层
* 从后门搬回来({@code AppRegistryIT} 用反射把这条钉成机械断言)。</p>
*
* <p>{@code app_code} 全库唯一({@code uk_app_code},<b>不含 is_deleted</b>):软删后 code 仍被占用,
* 不复用(P125)。它是 oapi 游标拉取的域段与 MQ routing key 的组成段,因此 {@code updatable=false}——
* 改名走 {@code name},code 一旦发布就等于对外契约的一部分(UserBase 那边贴的是含它的入口链接)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "app_registry",
uniqueConstraints = @UniqueConstraint(name = "uk_app_code", columnNames = {"app_code"}))
public class AppRegistry extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 全局唯一人读 slug(C10 格式;保留字 {@code form-center} 不可注册),发布后不改 */
@Column(name = "app_code", nullable = false, length = 64, updatable = false)
private String appCode;
/** 应用显示名 */
@Column(name = "name", nullable = false, length = 128)
private String name;
/** 建立者会话校;0=平台级(仅超管可建) */
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
/** 软删标记——唯一删除形态;在用(名下有未软删表单)禁删(§5.2) */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
}
@@ -65,6 +65,13 @@ public class Form extends BaseEntity {
@Column(name = "name", nullable = false, length = 128) @Column(name = "name", nullable = false, length = 128)
private String name; private String name;
/**
* 创建人 user_id(C16:{@code create_by} 存的是账号名字符串,与管理员两表的 user_id 标识体系不同,
* 建列归一)。新建时从会话落列;存量行=NULL,创建人判定按 {@code create_by} 账号名兜底。
*/
@Column(name = "creator_id", updatable = false)
private Long creatorId;
/** 当前发布版本指针,null=从未发布(resolve 只认它,方案 §5.1) */ /** 当前发布版本指针,null=从未发布(resolve 只认它,方案 §5.1) */
@Column(name = "current_version") @Column(name = "current_version")
private Integer currentVersion; private Integer currentVersion;
@@ -77,9 +84,13 @@ public class Form extends BaseEntity {
@Column(name = "status_defs", columnDefinition = "text") @Column(name = "status_defs", columnDefinition = "text")
private String statusDefs; private String statusDefs;
/** 面向人群:1 学生 / 2 教师(数据范围默认矩阵的输入,契约 §10) */ /**
@Column(name = "audience", nullable = false, columnDefinition = "tinyint") * 面向人群(契约 v2 C1 多选):逗号分隔枚举码串 {@code STUDENT,TEACHER,ANONYMOUS},
private Integer audience = FormEnums.Audience.STUDENT.getCode(); * 列型 VARCHAR(64)(原 TINYINT 1/2 由 {@code sql/t12_access_simplification.sql} 迁移)。
* 读写一律过 {@link FormEnums.Audience} 的 parse/join,不在别处拼字符串。
*/
@Column(name = "audience", nullable = false, length = 64)
private String audience = FormEnums.Audience.STUDENT.name();
/** 限填次数:0 不限 / 1 限一次(去重维度按 subject/submitter 推导,方案 §5.4) */ /** 限填次数:0 不限 / 1 限一次(去重维度按 subject/submitter 推导,方案 §5.4) */
@Column(name = "fill_limit", nullable = false, columnDefinition = "tinyint") @Column(name = "fill_limit", nullable = false, columnDefinition = "tinyint")
@@ -93,6 +104,17 @@ public class Form extends BaseEntity {
@Column(name = "answer_no_enabled", nullable = false, columnDefinition = "tinyint") @Column(name = "answer_no_enabled", nullable = false, columnDefinition = "tinyint")
private Integer answerNoEnabled = 0; private Integer answerNoEnabled = 0;
/**
* 提交后禁编辑(契约 v2 §4.2/D11):1=开启,提交人编辑与管理员编辑<b>双写通道同关</b>(V61),
* 修正路径只剩"软删(撤回)+ 重新提交"。默认关——喂外部业务应用的表单由设计者显式开启。
*/
@Column(name = "frozen", nullable = false, columnDefinition = "tinyint")
private Integer frozen = 0;
/** 匿名 cookie 防重窗口分钟数(C7,默认 10);仅 audience 含 ANONYMOUS 的表单消费 */
@Column(name = "anon_cookie_minutes", nullable = false)
private Integer anonCookieMinutes = 10;
/** 多表单快应用的菜单分组标签(方案 §5.1,可选) */ /** 多表单快应用的菜单分组标签(方案 §5.1,可选) */
@Column(name = "group_tag", length = 64) @Column(name = "group_tag", length = 64)
private String groupTag; private String groupTag;
@@ -0,0 +1,44 @@
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;
/**
* 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的<b>数据查看 + 导出</b>授权(D3 裁决,
* 编辑权=该表 ALL 档下的管理动作(契约 v2 C4②),本表不扩定义权)。
*
* <p>{@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与
* 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为
* {@code SCHOOL}(跨校学生数据不因授权而可见,D2 裁决)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_admin",
uniqueConstraints = @UniqueConstraint(name = "uk_form_admin", columnNames = {"form_uuid", "user_id"}),
indexes = @Index(name = "idx_form_admin_user", columnList = "user_id"))
public class FormAdmin extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 授权的表单(按 uuid,跨版本不变——契约 §1) */
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
/** 冗余表单租户域(form.sch_id,基础表单=0) */
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
@Column(name = "user_id", nullable = false, updatable = false)
private Integer userId;
/** 姓名快照,仅供列表展示 */
@Column(name = "user_name", nullable = false, length = 64)
private String userName;
}
@@ -0,0 +1,73 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 消费应用对某张表单的显式授权(消费应用授权 v3 §2,TASK_1)。
*
* <p><b>三元唯一</b>:{@code (form_uuid, consumer_school_id, consumer_app_key)}——授权是
* "这张表允许这个租户的这个应用读/订阅",租户与应用缺一不可。只按 appKey 授权会丢掉租户维度,
* 而"读答卷仍以该校行集限定"(v3 §2)正是靠 {@code consumer_school_id} 落型的:
* 平台级表单({@code form.sch_id=0})对某校授予后,答卷行集依旧按那一校限定。</p>
*
* <p><b>默认拒绝</b>:读与订阅是分开的两把开关,都没有隐式全集;owner 应用天然有读权,
* 但订阅同样要显式开一行(读/写分权是这一版的红线)。撤销不删行,改 {@code enabled=0}
* 并让 {@code grantVersion} 进位——留着行才答得出"什么时候撤的权"。</p>
*
* <p>{@code grantVersion} 是给 TASK_3 事件面用的:订阅者必须带回版本号,消费方才能识别
* "这条推送用的授权已经作废",不允许那边再从库里自己拼一套授权判定。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_consumer_grant",
uniqueConstraints = @UniqueConstraint(name = "uk_grant",
columnNames = {"form_uuid", "consumer_school_id", "consumer_app_key"}),
indexes = @Index(name = "idx_grant_consumer", columnList = "consumer_app_key, consumer_school_id, enabled"))
public class FormConsumerGrant extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 被授权的表单(全局 uuid) */
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
/** 消费租户:授权生效的学校域,也是读答卷时的行集限定域 */
@Column(name = "consumer_school_id", nullable = false)
private Long consumerSchoolId;
/** 消费应用的平台身份(appKey,非 app_code) */
@Column(name = "consumer_app_key", nullable = false, length = 64)
private String consumerAppKey;
/** 读授权(目录/schema/answer/answers):1=允许 */
@Column(name = "read_allowed", nullable = false, columnDefinition = "tinyint")
private Integer readAllowed = 0;
/** 订阅授权(答卷变更事件的数据订阅权,投影应用/权威对账授权都判它):1=允许 */
@Column(name = "subscribe_allowed", nullable = false, columnDefinition = "tinyint")
private Integer subscribeAllowed = 0;
/**
* MQ broker 推送投递(2026-10-22 决策②:读取/订阅/MQ 推送三权分立)。
* 1=该订阅的事件进 outbox 并经 broker 投递;<b>0=HTTP-only(拉取+收据确认),不建 outbox 行</b>,
* 避免没接 broker 的消费方在 fc_answer_delivery 里长期 PENDING。翻转让 grant_version 进位。
*/
@Column(name = "push_allowed", nullable = false, columnDefinition = "tinyint")
private Integer pushAllowed = 0;
/** 生效开关:0=已撤销(保留行与 grant_version 供审计,撤销后后续读取/发布检查即失败) */
@Column(name = "enabled", nullable = false, columnDefinition = "tinyint")
private Integer enabled = 1;
/** 授权版本号:每次撤销/变更进位,订阅投递与消费方幂等判定都带它 */
@Column(name = "grant_version", nullable = false)
private Integer grantVersion = 1;
}
@@ -0,0 +1,49 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 表单的资源拥有者绑定(消费应用授权 v3 §2,TASK_1)。
*
* <p><b>它只回答一件事</b>:这张表单(按全局 uuid)是哪个平台应用(appKey)的资产。
* 拥有者与消费租户是两件事——{@code owner_app_key} 是资产归属,"别的应用能不能读它"一律走
* {@link FormConsumerGrant},所以 owner 与 appCode 之间没有任何推导关系:
* 客户端自报的 {@code appCode} 只能筛资源,永远不授予拥有权(v3 §1)。</p>
*
* <p><b>owner 由谁写</b>:只有管理面(校管理员/超管,见 {@code FormAccessController})能设置,
* 且写入前用平台密钥注册表核验过 appKey 确实在 UserBase 注册存在;签名应用不能自报 owner。
* 缺归属行=没有拥有者,除显式 grant 外一律拒(fail-closed,默认拒绝)。</p>
*
* <p>{@code form_school_id} 存的是绑定时表单自己的 {@code sch_id}(0=平台级),
* 作为"这条绑定属于哪个资源域"的留痕:管理面的域判定用它复核,防止日后表单换校
* 让一条跨校绑定看起来仍然合法。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_ownership",
uniqueConstraints = @UniqueConstraint(name = "uk_ownership_form", columnNames = "form_uuid"),
indexes = @Index(name = "idx_ownership_owner", columnList = "owner_app_key, form_school_id"))
public class FormOwnership extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 资源坐标:表单全局 uuid(契约 §1:不可枚举、跨版本不变);一表一绑定 */
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
/** 绑定时表单的归属校;0=平台级表单 */
@Column(name = "form_school_id", nullable = false)
private Long formSchoolId;
/** 拥有者应用的平台身份(UserBase 注册分配的 appKey,不是 app_code) */
@Column(name = "owner_app_key", nullable = false, length = 64)
private String ownerAppKey;
}
@@ -0,0 +1,43 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 校级表单管理员(T10 / 管理员体系方案 §4①):数据范围扩展的载体,取代已退役的 UserBase 侧
* customId 权限锚点行(方案 §7)。
*
* <p><b>它只升级数据范围,不是页面入口凭证</b>(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权
* 角色门判({@code FormAdminGuard}+{@code FormAccessPolicy.isSchoolAdmin}),这张表只让 {@code DataScopeService}
* 把范围从"SELF"升到
* 本校表单=全部、基础表单=本校提交。</p>
*
* <p>{@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权
* ——解析入口是会话,会话失效即判不到(方案 §8)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "school_admin",
uniqueConstraints = @UniqueConstraint(name = "uk_school_admin", columnNames = {"sch_id", "user_id"}))
public class SchoolAdmin extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 租户键(= 会话校;超管代查时=显式指定的目标校) */
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
/** UserBase 用户主键({@code BoaUser.id}) */
@Column(name = "user_id", nullable = false, updatable = false)
private Integer userId;
/** 姓名快照,仅供列表展示 */
@Column(name = "user_name", nullable = false, length = 64)
private String userName;
}
@@ -0,0 +1,101 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 可信提交上下文(answer-delivery-v2 §6,TASK_2 A.2/A.3)。
*
* <p><b>它解决的是"答卷到底是不是这次业务意图产生的"这一个事实</b>:办事大厅先建意图、
* 再让被嵌页填报,中心必须能把答卷钉回那次意图,而不是相信浏览器带来的 bizId/answerId。
* 三条形状因此都是必需的:</p>
* <ul>
* <li><b>只存 token 摘要</b>:明文 {@code contextToken} 只在签发响应里出现一次;
* 库被读也不能拿它去兑换别人的填报(防"拖库即冒充");</li>
* <li><b>唯一键 (consumer_app_key, intent_id)</b>:同意图重试返回<b>同一</b>有效上下文,
* 结构上不允许出现"第二个业务意图"(A.2);</li>
* <li><b>唯一键 answer_uuid</b>:一份答卷只能原子关联到一个意图,
* "同 token 同幂等键返回原答卷、不同内容重用拒绝"在库级就有兜底(A.3)。</li>
* </ul>
*
* <p>{@code embed_origin}/{@code callback_url} 由<b>服务端配置/注册信息</b>填写(签发时落定),
* 请求体里没有这两个字段——浏览器给的任何 URL 都不采信(A.2 末句)。
* {@code submitter_id} 是消费方服务端断言的填报人,中心在 redeem 时与 CAS 会话逐字复核,
* 对不上就是 {@code FORBIDDEN}(token 错人)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "submission_context",
uniqueConstraints = {
@UniqueConstraint(name = "uk_ctx_token_digest", columnNames = "token_digest"),
@UniqueConstraint(name = "uk_ctx_intent", columnNames = {"consumer_app_key", "intent_id"}),
@UniqueConstraint(name = "uk_ctx_answer", columnNames = "answer_uuid")
},
indexes = {@Index(name = "idx_ctx_form", columnList = "form_uuid, form_version")})
public class SubmissionContext extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 消费应用 appKey(验签后的 yh.app.auth,不是请求体里的 appCode) */
@Column(name = "consumer_app_key", nullable = false, length = 64, updatable = false)
private String consumerAppKey;
/** 消费租户域(授权与行集限定都用它,v3 §2 的两个维度之一) */
@Column(name = "consumer_school_id", nullable = false, updatable = false)
private Long consumerSchoolId;
/** 消费方业务意图键(服务端生成,≤64) */
@Column(name = "intent_id", nullable = false, length = 64, updatable = false)
private String intentId;
/** 钉定的表单坐标(成对,签发时即按 v3 §6 校验过版本行存在) */
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
@Column(name = "form_version", nullable = false, updatable = false)
private Integer formVersion;
/** 解析出来的资源坐标(hosted 页提交时按它寻址,不再走 code 解析) */
@Column(name = "app_code", nullable = false, length = 64, updatable = false)
private String appCode;
@Column(name = "code", nullable = false, length = 64, updatable = false)
private String code;
/** 消费方断言的填报人(hosted 首批=登录本人;redeem 时与会话逐字比对) */
@Column(name = "submitter_id", nullable = false, updatable = false)
private Integer submitterId;
/** contextToken 的 SHA-256(明文不入库) */
@Column(name = "token_digest", nullable = false, length = 64, updatable = false)
private String tokenDigest;
/** 签发时锁定的可嵌入父页面 origin(精确 scheme+host+port) */
@Column(name = "embed_origin", nullable = false, length = 200)
private String embedOrigin;
/** 签发时锁定的消费方确认入口(服务端配置,非浏览器传入) */
@Column(name = "callback_url", nullable = false, length = 500)
private String callbackUrl;
@Column(name = "expires_at", nullable = false, updatable = false)
private LocalDateTime expiresAt;
/** 答卷原子关联(提交事务内写;NULL=尚未填报。uk_ctx_answer 允许多个 NULL) */
@Column(name = "answer_uuid", length = 32)
private String answerUuid;
@Column(name = "linked_revision")
private Long linkedRevision;
@Column(name = "linked_at")
private LocalDateTime linkedAt;
}
@@ -1,5 +1,11 @@
package com.yunhai.formcenter.enums; package com.yunhai.formcenter.enums;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/** /**
* 定义域枚举(契约 §1/§10 + 方案 §5.1/§5.4,一期冻结)。 * 定义域枚举(契约 §1/§10 + 方案 §5.1/§5.4,一期冻结)。
* 列值直接用契约 §10 DDL 草案里的数字口径,避免库内值与文档两套。 * 列值直接用契约 §10 DDL 草案里的数字口径,避免库内值与文档两套。
@@ -35,42 +41,112 @@ public final class FormEnums {
} }
} }
/** 面向人群:1 面向学生 / 2 面向教师(数据范围默认矩阵的输入,方案 §6.4;只决定默认,授权仍看模块行) */ /**
* 面向人群(契约 v2 C1:多选,列存逗号分隔枚举码 {@code VARCHAR(64)},JSON 面一律字符串数组)。
*
* <p>闭集三值:学生 / 教师 / 匿名可共集于一张表单(D8:禁止布尔补丁,不新增 {@code is_anonymous} 列)。
* {@code ANONYMOUS} 只对匿名通道生效,登录会话不可能持有它({@code FormAccessPolicy#identityTypes})。</p>
*
* <p>存量迁移由 {@code sql/t12_access_simplification.sql} 完成({@code 1→STUDENT}、{@code 2→TEACHER})。</p>
*/
public enum Audience { public enum Audience {
STUDENT(1), TEACHER(2); STUDENT, TEACHER, ANONYMOUS;
private final int code; /** 枚举名解析(未知=null,调用侧按拒绝处理,不静默当成某个身份) */
public static Audience of(String code) {
Audience(int code) { String clean = code == null ? null : code.trim();
this.code = code; if (clean == null || clean.isEmpty()) {
return null;
} }
public int getCode() {
return code;
}
public static boolean valid(Integer code) {
if (code != null) {
for (Audience a : values()) { for (Audience a : values()) {
if (a.code == code) { if (a.name().equalsIgnoreCase(clean)) {
return true; return a;
}
}
}
return false;
}
/** 对外下发用枚举名(契约 §4 的 {@code "audience":"STUDENT"}),不外露数字 */
public static String nameOf(Integer code) {
if (code != null) {
for (Audience a : values()) {
if (a.code == code) {
return a.name();
}
} }
} }
return null; return null;
} }
public static boolean validName(String code) {
return of(code) != null;
}
/**
* 列值码串 → 身份集合(按枚举定义序去重)。
*
* <p>空/NULL=按默认 {@code STUDENT} 解释(C1 注记"存量脏值不放宽":宁少给一个人群,
* 也不因脏列值把表单暴露给更多人)。非法码直接拒——不静默忽略,那会把"配错了人群"
* 伪装成"这些人本来就不能填"。</p>
*/
public static Set<Audience> parse(String columnValue) {
String clean = columnValue == null ? null : columnValue.trim();
Set<Audience> result = EnumSet.noneOf(Audience.class);
if (clean == null || clean.isEmpty()) {
result.add(STUDENT);
return result;
}
for (String token : clean.split(",")) {
String name = token.trim();
if (name.isEmpty()) {
continue;
}
Audience audience = of(name);
if (audience == null) {
throw new IllegalArgumentException("audience 只能是 STUDENT/TEACHER/ANONYMOUS:" + name);
}
result.add(audience);
}
if (result.isEmpty()) {
result.add(STUDENT);
}
return result;
}
/** 身份集合 → 列值码串(按枚举定义序,稳定可比对) */
public static String join(Collection<Audience> audiences) {
StringBuilder sb = new StringBuilder();
for (Audience a : values()) {
if (audiences != null && audiences.contains(a)) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(a.name());
}
}
return sb.length() == 0 ? STUDENT.name() : sb.toString();
}
/** 列值码串 → 枚举名数组(对外出参一律数组,C1/C5;顺序=枚举定义序) */
public static List<String> namesOf(String columnValue) {
List<String> names = new ArrayList<>();
for (Audience a : parse(columnValue)) {
names.add(a.name());
}
return names;
}
/** 列值码串 → 中文文案(audience 门拒绝文案要能诊断出配了哪些人群,§3.1) */
public static String labelOf(String columnValue) {
StringBuilder sb = new StringBuilder();
for (Audience a : parse(columnValue)) {
if (sb.length() > 0) {
sb.append('/');
}
sb.append(a.label());
}
return sb.toString();
}
/** 展示名(只用于文案,不参与存储与线格式) */
public String label() {
switch (this) {
case TEACHER:
return "教师";
case ANONYMOUS:
return "匿名";
default:
return "学生";
}
}
} }
/** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */ /** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */
@@ -101,17 +177,21 @@ public final class FormEnums {
} }
/** /**
* 数据范围(契约 §8 / 方案 §6.4):<b>闭集四枚举,一期契约即含全部四值</b>—— * 数据范围<b>两档</b>(契约 v2 §2.3 / D4):
* 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。 * <ul>
* <li>{@link #ALL}=该表单全部行;持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。
* 校管理员×平台级表单({@code sch_id=0})是 <b>ALL 档内的租户限定变体</b>(D9:只给本校提交行,
* 谓词 {@code submitter_sch_id=会话校}),不是第三个范围值;</li>
* <li>{@link #SELF}=我提交的 ∪ 关于我的({@code subject_id}=会话用户)——登录用户天然持有,
* 因此不存在"全不命中回落"分支。</li>
* </ul>
* *
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK): * <p>原四值闭集(全部/任教班级/仅本人提交/关于我)+ {@code SCHOOL} + 身份默认矩阵随
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p> * 《契约v2》§19 作废-1 退役;任教班级/按组圈人属业务语义推断,需要时任课数据源落实后单独开批次。</p>
*/ */
public enum DataScope { public enum DataScope {
ALL("全部"), ALL("全部"),
TEACHING_CLASS("任教班级"), SELF("我相关");
SELF_SUBMITTED("仅本人提交"),
ABOUT_ME("关于我");
private final String label; private final String label;
@@ -122,15 +202,5 @@ public final class FormEnums {
public String getLabel() { public String getLabel() {
return label; return label;
} }
/** 枚举名解析(对外只暴露名字,数字序号不外露——加值时不破坏兼容) */
public static DataScope of(String name) {
for (DataScope scope : values()) {
if (scope.name().equalsIgnoreCase(name == null ? null : name.trim())) {
return scope;
}
}
return null;
}
} }
} }
@@ -0,0 +1,45 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 管理员两表的出入参模型(T10 方案 §5.3,中心 {@code /api} 面、信封 code=0 族)。
*
* <p>与契约 §1 的"库内 id 不外露"唯一有出入的地方是 {@link AdminView#getId()}:
* 方案 §5.3 的删除端点形态就是 {@code DELETE /…/{id}}(管理员行没有 uuid 可用),
* 该 id 只在本人的管理面内出现、且删除前还要按 {@code (id, 会话域)} 复核归属,
* 不构成答卷数据面那种可枚举资源标识。取舍记在 T10 报告。</p>
*/
public final class AdminModels {
private AdminModels() {
}
/** 选人弹窗提交的一个候选人(userId=UserBase 主键;userName 只做展示快照) */
@Getter
@Setter
public static class AdminUserItem {
private Integer userId;
private String userName;
}
/** 批量添加请求体:{@code schId} 只是超管的目标域,校级传别的值即拒(同管理面口径) */
@Getter
@Setter
public static class AdminAddRequest {
private Long schId;
private List<AdminUserItem> users;
}
/** 列表行(每校/每表单个位数,一律不分页,方案 §4) */
@Getter
@Setter
public static class AdminView {
private Long id;
private Integer userId;
private String userName;
}
}
@@ -0,0 +1,48 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
/**
* 匿名通道出入参(契约 v2 §3.2/§3.3,T15)。
*
* <p><b>为什么单独一个模型而不是复用 {@link AnswerModels.SubmitRequest}</b>:那个 DTO 里有
* {@code subjectId}。匿名请求的字段闭集(§3.3)<b>不含任何身份项</b>——把 DTO 分开,"服务端不读
* schId/userId/subjectId"就不是一条纪律而是一条编译期事实(V17 的匿名分支,契约 §7 身份行)。</p>
*/
public final class AnonModels {
private AnonModels() {
}
/** 匿名提交请求(字段闭集:寻址两件 + 幂等键 + 值文档) */
@Getter
@Setter
public static class SubmitRequest {
/** 表单 code(匿名面唯一寻址键,契约 §3.2) */
private String formCode;
/** 归属应用,缺省 form-center(契约 §3.3 的匿名示例体没有这一项,落地差额见问题单 P122) */
private String appCode;
/** 客户端生成的幂等键(防双击语义,与防刷无关;命中=200+原 answerId,C7) */
private String idempotencyKey;
/** 值文档 {@code {key:value}},与登录面同一套按钉住版本的校验 */
private JsonNode values;
}
/**
* 匿名提交的服务端上下文:只允许从 HTTP 请求本身取(IP/UA 是留痕,防重秒数是 cookie 校验结果),
* 请求体里没有任何字段能影响它。
*/
@Getter
@RequiredArgsConstructor
public static class Context {
/** 留痕 IP({@code answer.anon_ip},C8/C12) */
private final String anonIp;
/** 留痕 User-Agent({@code answer.anon_ua},C8/C12) */
private final String anonUa;
/** 防重窗口剩余秒数;0=放行(无 cookie / 签名无效 / 窗口已过,C7) */
private final long throttleRemainingSeconds;
}
}
@@ -0,0 +1,301 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 答卷投递 v2 的出入参(answer-delivery-v2 契约,TASK_2)。
*
* <p>三条形状纪律:</p>
* <ul>
* <li><b>信封字段闭集</b>:{@link SnapshotEnvelope} 逐字段标 {@link JsonProperty}——
* 契约里写的是 camelCase,靠命名策略碰巧对上是脆弱的,多一个字段就该被当成违约(同 v1 §6.2 的
* {@code FcEventPublisherTest} 立场);</li>
* <li><b>身份不进消费方请求</b>:{@link ContextIssueRequest} 里没有 appKey/consumer 身份位,
* 调用方身份只取验签后的 {@code yh.app.auth}(v3 §1,TASK_1 已定的口径);
* 浏览器侧更不会出现在这条链上(这是服务端到服务端的签名调用);</li>
* <li><b>回执与投递状态分开</b>:{@code brokerAccepted}(总线收下)与
* {@code consumerApplied}(消费方业务落库并回执)是两个时刻、两组列,
* 谁也不能替谁作证(B.4/B.6)。</li>
* </ul>
*/
public final class AnswerDeliveryModels {
/** v2 协议号(同时锁定 {@code AnswerCanonicalizer.fc.canon.v1} 的哈希口径) */
public static final String PROTOCOL_VERSION = "fc.answer.snapshot.v2";
/** v2 专用 exchange(与 v1 广播 exchange 物理分开,见配置与部署契约) */
public static final String EXCHANGE = "fc.answer.delivery.v2";
/** 事件类型闭集:值/状态变更都归 {@code updated},墓碑单列 */
public static final String EVENT_CREATED = "created";
public static final String EVENT_UPDATED = "updated";
public static final String EVENT_TOMBSTONED = "tombstoned";
private AnswerDeliveryModels() {
}
/** 消费端扩展字段的类型标记(消费方本地列,永不参与快照与哈希) */
@Getter
@Setter
public static class Snapshot {
private Long schId;
private String appCode;
private String code;
private Integer submitterId;
private Integer subjectId;
private String status;
private JsonNode values;
private String answerNo;
/** ISO-8601 本地日期时间({@code yyyy-MM-ddTHH:mm:ss}),固定形态、不带时区偏移 */
private String createTime;
private String updateTime;
/** 仅对本 consumer 可见的意图关联(不参与 contentHash,见 fc.canon.v1 规则⑥) */
private JsonNode intent;
}
/** v2 信封({@code fc.answer.delivery.v2} 的消息体) */
@Getter
@Setter
public static class SnapshotEnvelope {
@JsonProperty("protocolVersion")
private String protocolVersion = PROTOCOL_VERSION;
@JsonProperty("eventId")
private String eventId;
@JsonProperty("eventType")
private String eventType;
/** 发布方(本表单中心)的应用身份,由服务端配置给,不接受消费方申报 */
@JsonProperty("sourceAppKey")
private String sourceAppKey;
@JsonProperty("consumerAppKey")
private String consumerAppKey;
@JsonProperty("consumerSchoolId")
private Long consumerSchoolId;
/** 投递行的授权纪元:撤权/能力变更即进位,消费端按它判"这封还作不作数" */
@JsonProperty("grantVersion")
private Integer grantVersion;
@JsonProperty("formUuid")
private String formUuid;
@JsonProperty("formVersion")
private Integer formVersion;
@JsonProperty("answerUuid")
private String answerUuid;
@JsonProperty("answerRevision")
private Long answerRevision;
@JsonProperty("isDeleted")
private Boolean isDeleted;
/** {@code fc.canon.v1} 下快照(剔除 intent)的 SHA-256 十六进制 */
@JsonProperty("contentHash")
private String contentHash;
@JsonProperty("occurredAt")
private String occurredAt;
@JsonProperty("snapshot")
private Snapshot snapshot;
}
// ==================== 提交上下文(A.2/A.4) ====================
/** 消费方申请可信提交上下文({@code POST /oapi/form/submission-context}) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ContextIssueRequest {
/** 消费方业务意图键(服务端生成,浏览器不可注入);同 (appKey,intentId) 重试返回同一上下文 */
private String intentId;
private Long consumerSchoolId;
/** 钉定的表单坐标(必须是服务器校验过的成对值,单传或"看起来对"都拒) */
private String formUuid;
private Integer formVersion;
/** 上下文绑定的填报人(hosted 首批=登录本人;FC 在 redeem 时逐字复核会话用户) */
private Integer submitterId;
}
/** 上下文签发结果(token 明文只在这一条响应里出现一次,库里只存摘要) */
@Getter
@Setter
public static class ContextIssueView {
private String intentId;
private String contextToken;
/** 消费方页面的精确 origin(服务端注册信息,浏览器传的一律不用) */
private String embedOrigin;
/** 消费方确认入口的绝对 URL(同上) */
private String callbackUrl;
private String formUuid;
private Integer formVersion;
private String appCode;
private String code;
private String formName;
private String expiresAt;
/** 同一意图重试命中原上下文=true(不新建第二个业务意图) */
private Boolean reused;
}
/** 上下文收据({@code GET /oapi/form/submission-context/{intentId}/receipt}) */
@Getter
@Setter
public static class ContextReceiptView {
/** PENDING=还没答卷;SUBMITTED=已原子关联答卷 */
private String status;
private String intentId;
private String consumerAppKey;
private Long consumerSchoolId;
private Integer submitterId;
private String formUuid;
private Integer formVersion;
private String answerUuid;
private Long answerRevision;
private String contentHash;
private String submittedAt;
private Boolean expired;
/** 收据带快照:可信建件不依赖"MQ 恰好已投递"(本地无 broker 时这条是唯一通路) */
private Snapshot snapshot;
}
// ==================== 投递回执与授权核验(B.6) ====================
/** 消费方应用结果回执({@code POST /oapi/form/answer-delivery/receipt}) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class AppliedReport {
private String eventId;
private String consumerAppKey;
private Long consumerSchoolId;
private String answerUuid;
private Long answerRevision;
/** APPLIED=本地已落库;IGNORED=按 revision 判定无需应用;CONFLICT=同版异 hash */
private String outcome;
/** 消费端复算的 hash(与信封一致才可能判 APPLIED) */
private String contentHash;
/** 消费端本地引用(办件号之类),只作审计,不参与幂等判据 */
private String localRef;
}
/** 回执处理结果(幂等:重复上报同一 eventId+consumer 返回原结论) */
@Getter
@Setter
public static class AppliedReportView {
private String eventId;
private String state;
private Boolean duplicate;
private String brokerAcceptedAt;
private String consumerAppliedAt;
}
/** 授权快照核验({@code POST /oapi/form/answer-delivery/grants/verify},批量、不回查值文档) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class GrantVerifyRequest {
private Long consumerSchoolId;
private List<String> formUuids = new ArrayList<>();
}
/** 单张表单的授权快照(消费端带 TTL 缓存它;查不到就是"当前不生效") */
@Getter
@Setter
public static class GrantSnapshotView {
private String formUuid;
private Long consumerSchoolId;
private String consumerAppKey;
private Boolean subscribeAllowed;
private Integer grantVersion;
/** 核验时点(ISO),消费端据此算缓存有效期 */
private String verifiedAt;
}
// ==================== 投递状态与 TASK_3 扩展点出参 ====================
/** 快照视图({@code AnswerSnapshotService.currentAuthorized}) */
@Getter
@Setter
public static class SnapshotView {
private String answerUuid;
private Long answerRevision;
private String contentHash;
private Boolean isDeleted;
private Snapshot snapshot;
}
/** 投递状态视图({@code replayAuthorized} 的返回;稳定状态码见枚举注释) */
@Getter
@Setter
public static class DeliveryStateView {
/**
* 稳定状态码(TASK_3 只调用不编辑,故码值不随文案变):
* {@code QUEUED}=已重新入队;{@code ALREADY_DELIVERED}=行已 brokerAccepted 且无异常;
* {@code GRANT_REVOKED}=当前授权不生效,不重放旧 grant;
* {@code REVISION_MISMATCH}=期望 revision 与库内当前值不符(历史版本内容不可重建)。
*/
private String state;
private String eventId;
private Long answerRevision;
private String message;
}
/** 失败投递分页({@code failedPage}) */
@Getter
@Setter
public static class FailedDeliveryPage {
private long total;
private List<FailedDeliveryView> rows = new ArrayList<>();
}
/** 单条失败投递(含死信形态,运维可查) */
@Getter
@Setter
public static class FailedDeliveryView {
private String eventId;
private String answerUuid;
private Long answerRevision;
private String consumerAppKey;
private Long consumerSchoolId;
private String state;
private Integer retryCount;
private String lastError;
private String nextRetryAt;
private String brokerAcceptedAt;
private String consumerAppliedAt;
}
/** hosted 填报页元数据({@code GET /api/form/hosted/schema}) */
@Getter
@Setter
public static class HostedFormView {
private String formUuid;
private String appCode;
private String code;
private String name;
/** 上下文钉住的确切版本(hosted 永不回落到当前发布版) */
private Integer version;
private List<String> audience;
private Integer fillLimit;
private String deadline;
private Boolean deadlineReached;
private Object groups;
private String expiresAt;
/** 消费方 origin:填报页 postMessage 的<b>唯一允许目标</b>(精确到 scheme+host+port) */
private String embedOrigin;
}
}
@@ -37,6 +37,14 @@ public final class AnswerModels {
private String idempotencyKey; private String idempotencyKey;
/** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */ /** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */
private Integer subjectId; private Integer subjectId;
/**
* 可选:可信提交上下文令牌(v2 契约 §6.3,TASK_2 A.3)。带它的提交就是 hosted 链路——
* 中心在<b>答卷事务内</b>兑换令牌、按令牌锁定的 {@code formUuid+formVersion} 钉版、
* 复核会话用户/学校,并把答卷原子关联回消费方的业务意图。
* 它<b>不是</b>身份载体:{@code schId/submitterId} 仍只由会话推导(V17 不变),
* 令牌里断言的人与会话不符即拒;也没有 bizId 位——浏览器传不进办件坐标。
*/
private String contextToken;
/** 值文档 {@code {key:value}},形状见契约 §3 */ /** 值文档 {@code {key:value}},形状见契约 §3 */
private JsonNode values; private JsonNode values;
} }
@@ -0,0 +1,44 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
/**
* 快应用注册表的出入参(契约 v2 C17/§5.2,T16)。
*
* <p>对外身份只有 {@code appCode}(人读 slug,也是入口链接与 MQ routing key 的组成段),
* 库内 {@code id} 一律不外露(契约 §1/V5);因此 PUT/DELETE 都按 code 寻址而不是按 id。</p>
*/
public final class AppModels {
private AppModels() {
}
/** 注册行:四个业务字段 + 中心生成的入口链接 */
@Getter
@Setter
public static class AppView {
private String appCode;
private String name;
/** 归属租户域;0=平台级(仅超管可建) */
private Long schId;
/** 应用入口链接(§5.2"由中心生成"),管理员复制后贴去 UserBase 建应用 */
private String entryUrl;
}
/** 建:app_code + name;{@code schId} 只有超管能用它指定目标域(校管理员一律按会话校落) */
@Getter
@Setter
public static class CreateRequest {
private String appCode;
private String name;
private Long schId;
}
/** 改:C17 只开改名(code 一旦发布就是对外坐标,不随改) */
@Getter
@Setter
public static class UpdateRequest {
private String name;
}
}
@@ -0,0 +1,133 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 消费应用授权与目录(v3,TASK_1)的模型。
*
* <p>三条不外露的线:库内 {@code id}(契约 §1/V5 同口径,寻址一律 uuid + 三元键)、
* 任何 appSecret(授权判定只用平台密钥注册表验"这个 appKey 存不存在",绝不回显密钥)、
* 归属差别信息(跨校/缺归属/不存在同一句拒绝,见 {@code FormConsumerAccessService})。</p>
*/
public final class FormAccessModels {
private FormAccessModels() {
}
/**
* 已验签的消费应用身份。唯一字段是 {@code appKey}——它由平台 {@code AppAuthFilter} 验签通过后
* 写在请求属性 {@code yh.app.auth} 上,本应用只是转读,没有任何"客户端自报身份"的入口。
*
* <p>{@code equals} 是给单测的透传断言用的(controller 层"只传不改"这件事要能按值比对),
* 不是给业务判权用的。</p>
*/
@Getter
@Setter
@lombok.EqualsAndHashCode
public static class ConsumerPrincipal {
private String appKey;
public ConsumerPrincipal() {
}
public ConsumerPrincipal(String appKey) {
this.appKey = appKey;
}
}
/** 订阅授权(v3 §7):{@code grantVersion} 必须随投递下发,消费方据此识别"这份授权已作废" */
@Getter
@Setter
public static class SubscriptionGrant {
private String formUuid;
private Long consumerSchoolId;
private String consumerAppKey;
private boolean readAllowed;
private boolean subscribeAllowed;
/** MQ broker 推送投递开关:false=HTTP-only(拉取+收据确认),入队侧不建 outbox 行 */
private boolean pushAllowed;
private int grantVersion;
}
// ==================== 目录(GET /oapi/form/forms) ====================
/** 目录行:够消费方选择器用的最小坐标集,选中后按 {@code formUuid}+{@code version} 存绑定 */
@Getter
@Setter
public static class CatalogItem {
private String formUuid;
private String code;
private String name;
private String appCode;
/** 当前已发布版本号(目录只出已发布行,所以它必然非空) */
private Integer version;
/** 面向人群(契约 v2 C5 形态:字符串数组) */
private List<String> audience;
}
/** 目录分页载荷:{@code {items,total}},{@code total}=过滤后的可读集大小(不是库内行数) */
@Getter
@Setter
public static class CatalogPage {
private List<CatalogItem> items;
private long total;
}
// ==================== 管理面 ====================
/** owner 绑定视图 */
@Getter
@Setter
public static class OwnershipView {
private String formUuid;
private Long formSchoolId;
private String ownerAppKey;
}
/** owner 绑定请求:只有 appKey 一件——归属校由服务端按表单自己的 {@code sch_id} 落,不信入参 */
@Getter
@Setter
public static class OwnerRequest {
private String ownerAppKey;
}
/** grant 视图(含已撤销行) */
@Getter
@Setter
public static class GrantView {
private String formUuid;
private Long consumerSchoolId;
private String consumerAppKey;
private boolean readAllowed;
private boolean subscribeAllowed;
/** MQ broker 推送投递开关(运维据此看出哪个消费方在吃推送) */
private boolean pushAllowed;
private boolean enabled;
private int grantVersion;
}
/** grant 授权请求(三元键 + 三把开关;不传开关=只给读,默认拒绝;push 依赖 subscribe) */
@Getter
@Setter
public static class GrantRequest {
private String formUuid;
private Long consumerSchoolId;
private String consumerAppKey;
private Boolean readAllowed;
private Boolean subscribeAllowed;
/** MQ推送:缺省/null对已有行保持、新行默认false;显式false才关闭,true依赖subscribe。 */
private Boolean pushAllowed;
}
/** grant 撤销请求 */
@Getter
@Setter
public static class RevokeRequest {
private String formUuid;
private Long consumerSchoolId;
private String consumerAppKey;
}
}
@@ -34,11 +34,16 @@ public final class FormModels {
private Integer currentVersion; private Integer currentVersion;
/** 待发布的草稿版本号,null=无草稿 */ /** 待发布的草稿版本号,null=无草稿 */
private Integer draftVersion; private Integer draftVersion;
private Integer audience; /** 面向人群(C1 多选,一律字符串数组:{@code ["STUDENT","TEACHER","ANONYMOUS"]}) */
private List<String> audience;
private Integer fillLimit; private Integer fillLimit;
/** yyyy-MM-dd HH:mm:ss,null=不限 */ /** yyyy-MM-dd HH:mm:ss,null=不限 */
private String deadline; private String deadline;
private Integer answerNoEnabled; private Integer answerNoEnabled;
/** 提交后禁编辑(契约 v2 §4.2):1=双写编辑通道同关 */
private Integer frozen;
/** 匿名 cookie 防重窗口分钟数(C7) */
private Integer anonCookieMinutes;
private String groupTag; private String groupTag;
private List<StatusDefView> statusDefs; private List<StatusDefView> statusDefs;
private String createTime; private String createTime;
@@ -73,7 +78,8 @@ public final class FormModels {
private String appCode; private String appCode;
private String name; private String name;
private Integer version; private Integer version;
private Integer audience; /** 面向人群(C1 数组形态;填报页据此判"我这个身份能不能填",判定权威仍在后端 audience 门) */
private List<String> audience;
private Integer fillLimit; private Integer fillLimit;
private Integer answerNoEnabled; private Integer answerNoEnabled;
private String deadline; private String deadline;
@@ -145,11 +151,16 @@ public final class FormModels {
@Setter @Setter
public static class FormConfigRequest { public static class FormConfigRequest {
private String uuid; private String uuid;
private Integer audience; /** 面向人群(C1 入参数组形态;null=不改这一项) */
private List<String> audience;
private Integer fillLimit; private Integer fillLimit;
/** yyyy-MM-dd(补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串/null=不限 */ /** yyyy-MM-dd(补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串/null=不限 */
private String deadline; private String deadline;
private Integer answerNoEnabled; private Integer answerNoEnabled;
/** 提交后禁编辑(契约 v2 §4.2):0/1,null=不改 */
private Integer frozen;
/** 匿名 cookie 防重窗口分钟数(C7):>0,null=不改 */
private Integer anonCookieMinutes;
private String groupTag; private String groupTag;
} }
@@ -0,0 +1,310 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 差异对账协议载荷(TASK_3,answer-reconcile-v1 §6)。
*
* <p><b>独立 DTO</b>:不往 {@code OapiModels}(TASK_1)与 {@code AnswerDeliveryModels}(TASK_2)里塞字段,
* 那两个文件的形状由各自任务负责。唯一的跨任务共享类型是
* {@link AnswerDeliveryModels.SnapshotEnvelope}/{@link AnswerDeliveryModels.Snapshot}——
* 修复信封必须与投递信封同一条规范化口径,复制一份就给了哈希分叉机会。</p>
*
* <p>{@code values} 一律只出现在 {@code Snapshot} 里、由签名任务端点按授权下发;
* 本文件的<b>任何</b>回报结构都不带值文档(§8 敏感数据纪律:错误明细里也不许出现)。</p>
*/
public final class FormSyncModels {
/** 协议号:锁死"清单游标=answerUuid、终态闭集、缺席需证明"这三条本轮约定 */
public static final String PROTOCOL = "fc.form-sync.v1";
/** 任务快照答复的存在性结论 */
public static final String PRESENCE_PRESENT = "PRESENT";
public static final String PRESENCE_ABSENT = "ABSENT";
public static final String PRESENCE_UNKNOWN = "UNKNOWN";
private FormSyncModels() {
}
// ==================== FC → SH:派发指令 ====================
/**
* 派发指令({@code POST /oapi/form-sync/reconcile} 的请求体)。
*
* <p>只带<b>任务坐标与清单规模</b>,不带任何答卷数据;地址来自服务端登记的后台服务地址,
* 浏览器提供的 callbackUrl 在这里没有任何对应字段。</p>
*/
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ReconcileInstruction {
private String protocol = PROTOCOL;
/** 任务对外身份(task_uuid) */
private String taskId;
/** 本次派发尝试(结果幂等键的另一半) */
private Integer attempt;
private String consumerAppKey;
private Long consumerSchoolId;
private String formUuid;
private Integer formVersion;
/** SCHEDULE / MANUAL */
private String triggerType;
/** 任务起点(yyyy-MM-dd'T'HH:mm:ss):本地 createTime 晚于此刻的行不得判为多余行 */
private String windowStart;
/** 清单物化完成时刻 */
private String materializedAt;
private Integer totalRows;
private Integer totalPages;
/** 建议页大小:消费端本地扫描与中心清单分页都用它,锁步比较才对得上 */
private Integer pageSize;
private String issuedAt;
}
/** 派发应答(对端 2xx 只说明指令被收下并持久化,不代表执行结果) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class InstructionAck {
private String taskId;
private Integer attempt;
private Boolean accepted;
/** 同 taskId 重复指令:已接收过,复用原任务与检查点 */
private Boolean duplicate;
private String state;
private String message;
}
// ==================== SH → FC:清单 / 快照 / 缺席 / 结果 ====================
/** 清单行(四元摘要 + 中心已核验的表单坐标) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ManifestRowView {
private String answerUuid;
private Long revision;
private String contentHash;
private Boolean isDeleted;
private String formUuid;
private Integer formVersion;
}
/**
* 清单分页:{@code completed=true} 才是集合结束,空页不是。
* 游标是最后一个 answerUuid,与消费端本地扫描同序、同语义。
*/
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ManifestPageView {
private String taskId;
private List<ManifestRowView> rows = new ArrayList<>();
private String nextCursor;
private Boolean completed;
private Integer totalRows;
private Integer totalPages;
/** 任务当前状态(CANCELLED 时消费端须停止剩余修复) */
private String taskState;
}
/**
* 任务快照答复:修复入口唯一的权威来源。
*
* <p>{@code presence=PRESENT} 时带 {@code envelope}(服务端按已核验坐标构造,
* formUuid/formVersion 来自任务行与答卷行,不从旧清单猜);
* {@code ABSENT} 时带 {@code absenceProof};{@code UNKNOWN}(清单未完整、期间新增、
* 范围改变、撤权)时两者都不给,只给原因——消费端据此只能报告差异,不能破坏本地数据。</p>
*/
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class TaskAnswerReply {
private String taskId;
private String consumerAppKey;
private Long consumerSchoolId;
private String formUuid;
private Integer formVersion;
private String answerUuid;
private String presence;
private Integer grantVersion;
private AnswerDeliveryModels.SnapshotEnvelope envelope;
private AbsenceProof absenceProof;
/** 无法给出权威结论时的稳定原因码(MANIFEST_INCOMPLETE / GRANT_REVOKED / CANCELLED / …) */
private String reasonCode;
private String reason;
private String verifiedAt;
}
/**
* 权威缺席证明(只在完整中心清单 + 新鲜授权核验都通过后签发)。
* {@code proofId} 是可追踪引用,不是身份凭证;{@code expectedRevision} 必填——
* 版本对不上本地行的退役请求会被消费端拒绝。
*/
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class AbsenceProof {
private String proofId;
private String taskId;
private String formUuid;
private Long schId;
private String answerUuid;
private Long expectedRevision;
private Integer grantVersion;
private String issuedAt;
}
/** 结果回报({@code POST /oapi/form-sync/tasks/{taskId}/result} 请求体) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ResultReport {
private String protocol = PROTOCOL;
private Integer attempt;
/** COMPLETED / PARTIAL / FAILED / CANCELLED / RUNNING(进度回报) */
private String state;
private Boolean localScanComplete;
private Boolean manifestComplete;
private Integer scannedRows;
private Integer pagesProcessed;
private Integer missingRepaired;
private Integer staleRepaired;
private Integer hashConflicts;
private Integer tombstonesRepaired;
private Integer localAhead;
private Integer deferred;
private Integer extraReported;
private Integer extraRetired;
/** 进程重启后从检查点续跑 */
private Boolean resumed;
private String startedAt;
private List<ReconcileError> errors = new ArrayList<>();
}
/** 一条错误:consumer / task / range / answer 四个坐标必须在场,值文档绝不在场 */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ReconcileError {
private String code;
private String taskId;
private String consumerAppKey;
private Long consumerSchoolId;
private String formUuid;
private String answerUuid;
private Long revision;
private String detail;
}
/** 结果受理答复:幂等与"旧 attempt 不倒退新 attempt"的结论都在这里回给消费端 */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ResultAck {
private String taskId;
private Integer attempt;
private Boolean accepted;
/** 同 taskId+attempt 的重复回报:原结论回给对端,不重复计数 */
private Boolean duplicate;
/** 比中心已记录的最新 attempt 更旧:留档但不推进状态 */
private Boolean staleAttempt;
private String taskState;
/** CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL */
private String decision;
private Integer reportAttempt;
private String resultState;
private String message;
}
// ==================== 管理面 ====================
/** 建任务请求:三种范围(单答卷 / 单表单 / 该消费方全部生效订阅)走同一条入口 */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class TaskCreateRequest {
/** ANSWER / FORM / ALL */
private String scope;
private String consumerAppKey;
private Long consumerSchoolId;
private String formUuid;
private String answerUuid;
/** 人工触发的幂等键(前端每次点击生成一个;缺键即由服务端补 uuid) */
private String idempotencyKey;
}
/** 任务视图(列表与详情共用) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class TaskView {
private String taskId;
private String consumerAppKey;
private Long consumerSchoolId;
private String formUuid;
private Integer formVersion;
private String triggerType;
private String operator;
private String state;
private String manifestState;
private Boolean manifestComplete;
private Integer totalRows;
private Integer totalPages;
private Integer materializedRows;
private Integer materializedPages;
private Integer attempt;
private String windowStart;
private String windowEnd;
private String dispatchedAt;
private String finishedAt;
private Boolean cancelRequested;
private String errorCode;
private String errorDetail;
/** 最近一次回报摘要(进度/差异计数) */
private ResultView latestResult;
}
/** 结果视图 */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ResultView {
private String taskId;
private Integer attempt;
private String state;
private Boolean localScanComplete;
private Boolean manifestComplete;
private Integer scannedRows;
private Integer pagesProcessed;
private Integer missingRepaired;
private Integer staleRepaired;
private Integer hashConflicts;
private Integer tombstonesRepaired;
private Integer localAhead;
private Integer deferred;
private Integer extraReported;
private Integer extraRetired;
private Integer errorCount;
private List<ReconcileError> errors = new ArrayList<>();
private Boolean resumed;
private String reportedAt;
}
/** 单答卷重放请求(管理面→TASK_2 {@code replayAuthorized}) */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ReplayRequest {
private String consumerAppKey;
private Long consumerSchoolId;
private String answerUuid;
private Long expectedRevision;
/** 操作幂等键:同一动作重复点击不刷新退避 */
private String operationId;
}
}
@@ -0,0 +1,44 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 批量导入的响应模型(契约 v2 C14,T18)。
*
* <p><b>逐行结果,坏行不静默</b>:导入是"把 Excel 里的一堆行交给提交管线",管线对每一行都可能给出
* 不同的答案(值校验/必填/截止/限次/被记录人不存在…),所以响应形状是每行一条 {@link RowResult},
* 而不是"整体成功/失败"。整批 500 行里 499 行成功必须能从响应里看出那 1 行为什么失败。</p>
*/
public final class ImportModels {
private ImportModels() {
}
/** {@code POST /api/form/admin/data/import} 的 data */
@Getter
@Setter
public static class Result {
/** 逐行结果,按文件里的行号顺序({@code rowNo} 是 xlsx 的物理行号,1 起) */
private List<RowResult> rows;
private int okCount;
private int failCount;
}
/** 单行结果(C14 的字段闭集就这三件可选两件:{@code {rowNo, ok, answerId?, error?}},不加扩展位) */
@Getter
@Setter
public static class RowResult {
private Integer rowNo;
private Boolean ok;
/**
* 成功时的答卷 uuid。命中幂等键(重复导入同一个文件,C13)时是<b>原</b>答卷 uuid——
* 契约明令"逐行返回 200+原 answerId",所以这种情况同样算 ok,不额外给"重复"标记位。
*/
private String answerId;
/** 失败原因=管线给的那句话原样(不在导入侧改写,否则同一个校验在两个地方有两种说法) */
private String error;
}
}
@@ -3,6 +3,8 @@ package com.yunhai.formcenter.model;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.util.List;
/** /**
* "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。 * "我的填写"两视图({@code /my/submit} 与 {@code /my/about})的列表出参(方案 §6.3、任务书 T8)。
* *
@@ -45,4 +47,68 @@ public final class MyModels {
/** yyyy-MM-dd HH:mm:ss */ /** yyyy-MM-dd HH:mm:ss */
private String submitTime; private String submitTime;
} }
/**
* 一行"我可填的表单"({@code GET /api/form/my/fillable},契约 v2 C2 行形状)。
*
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页"。
* 字段=表单身份 + 配置项,<b>不含任何入口层结论</b>:原 P95 的 {@code canFill}/{@code myTier}
* 随三层模型出清(§19 作废-3)——入集本身就等价于"能填"(登录身份类型 ∈ audience),
* 不再有"进了列表但不能填"的中间态。</p>
*
* <p>{@code audience} 下数组(C1);{@code ANONYMOUS} 不参与登录会话的入集判定
* (匿名填报人不看列表,持 URL 直达)。</p>
*/
@Getter
@Setter
public static class FillableRow {
private String code;
private String name;
private String appCode;
/** 面向人群(C1 数组形态) */
private List<String> audience;
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */
private String deadline;
/** 限填次数:0 不限 / 1 限一次 */
private Integer fillLimit;
private String groupTag;
}
/**
* 一行"我管理的表单"(契约 v2 C3,{@code GET /api/form/my/managed}):
* 行形状=fillable 行 + {@code role} 来源标记数组,菜单自解析的"管理"支路数据源。
*
* <p>{@code role} 取值闭集 {@code creator}(C16 判定)/{@code form_admin}(两表命中),
* 两个来源同时命中时数组有两个元素——前端要能说出"你为什么管得动这张表"。</p>
*
* <p><b>与 fillable 的两处刻意差别</b>:① 不做遮蔽去重(管理清单要"我能碰的每一张表",
* 同 code 的本校草稿与平台级表单是两条不同的可操作资产,藏掉一条就再也进不去);
* ② 不限生命周期(草稿/停用行同样要出现在管理支路,否则建了草稿的人找不到入口继续编辑)。</p>
*/
@Getter
@Setter
public static class ManagedRow extends FillableRow {
/**
* 表单 uuid(P120 落地差额:C3 只写了"fillable 行 + role",但管理支路没有 uuid 就点不进
* 设计器/数据页——数据面与定义面的寻址都是 uuid,code 只作入口)。
*/
private String formUuid;
/** 生命周期(0 草稿/1 已发布/2 停用):管理支路要标出"这张还没发布" */
private Integer status;
/** 创建人=超管/校管理员之外的人,靠这一行拿到继续编辑自己草稿的入口(D6 不设移交) */
private List<String> role;
}
/**
* 能力位(契约 v2 §2.4 三支路自解析的第三支):前端"系统设置"入口的唯一数据源。
*
* <p>只有这一个布尔,是给前端画菜单用的——<b>不是权限</b>:接口侧每个端点各自按 C4 的角色门把关,
* 前端藏掉入口不等于没权限(契约 §7 身份行)。</p>
*/
@Getter
@Setter
public static class Capabilities {
/** 校管理员(超管恒 true):管理员两表 / 快应用注册 / GetSchoolAll 的入口位 */
private boolean schoolAdmin;
}
} }
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.util.List;
/** /**
* 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。 * 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。
* *
@@ -27,8 +29,12 @@ public final class OapiModels {
private Integer version; private Integer version;
private String code; private String code;
private String appCode; private String appCode;
/** STUDENT / TEACHER(枚举名下发,不外露数字,契约 §4 示例) */ /**
private String audience; * 面向人群(契约 v2 C5/G3:<b>breaking</b>——由单值串改字符串数组,与 T13 改库同批切换,
* 不加适配层;闭集 {@code STUDENT}/{@code TEACHER}/{@code ANONYMOUS},可共集于一张表单)。
* 消费方通告:flow-center 只在流程定义<b>发布期</b>解析本字段,部署前发通告。
*/
private List<String> audience;
/** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */ /** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */
private JsonNode groups; private JsonNode groups;
} }
@@ -69,4 +75,92 @@ public final class OapiModels {
private String code; private String code;
private Integer version; private Integer version;
} }
// ==================== v2 拉取端点(§6.1,T17) ====================
/**
* {@code GET /oapi/form/answers} 的 data:{@code {rows, nextCursor}}(C9)。
*
* <p>外层两个键按契约原文是 <b>camelCase</b>,而 {@link AnswerRow} 内部是 <b>snake_case</b>——
* 这不是笔误而是原文如此(§6.1 的行样例 + C9 的响应样例),实现照抄,不在中间加"顺手统一"的适配层:
* 线格式一改,已接的消费方就得跟着改(同 C5/G3 那次 breaking 的教训)。</p>
*/
@Getter
@Setter
public static class AnswersView {
private java.util.List<AnswerRow> rows;
/** null=已到当前窗口末尾;同游标重放返回同窗口或其后子集(C9 重放安全) */
private String nextCursor;
}
/**
* 拉取行的<b>字段闭集</b>(§6.1,V53:库内 {@code id} 绝不出现在响应里)。
*
* <p>逐字段标 {@code @JsonProperty} 而不是配命名策略:这张表一旦有人"顺手"加字段,
* {@code OapiAnswersIT} 的键集断言就会红(闭集是契约级的,不是实现细节)。
* 软删行照常出现在这里({@code is_deleted=1} 即墓碑,C11)。</p>
*/
@Getter
@Setter
public static class AnswerRow {
@com.fasterxml.jackson.annotation.JsonProperty("answer_uuid")
private String answerUuid;
@com.fasterxml.jackson.annotation.JsonProperty("sch_id")
private Long schId;
@com.fasterxml.jackson.annotation.JsonProperty("app_code")
private String appCode;
@com.fasterxml.jackson.annotation.JsonProperty("form_uuid")
private String formUuid;
@com.fasterxml.jackson.annotation.JsonProperty("form_code")
private String formCode;
@com.fasterxml.jackson.annotation.JsonProperty("version")
private Integer version;
@com.fasterxml.jackson.annotation.JsonProperty("answer_no")
private String answerNo;
/** 提交人;0=匿名行(C8) */
@com.fasterxml.jackson.annotation.JsonProperty("submitter_id")
private Integer submitterId;
/** 被代填人;自填=本人、匿名=NULL */
@com.fasterxml.jackson.annotation.JsonProperty("subject_id")
private Integer subjectId;
@com.fasterxml.jackson.annotation.JsonProperty("status")
private String status;
@com.fasterxml.jackson.annotation.JsonProperty("values")
private JsonNode values;
/** 匿名留痕(C12:登录答卷两项恒 null,值文档里也不出现) */
@com.fasterxml.jackson.annotation.JsonProperty("meta")
private AnswerRowMeta meta;
@com.fasterxml.jackson.annotation.JsonProperty("create_time")
private String createTime;
@com.fasterxml.jackson.annotation.JsonProperty("update_time")
private String updateTime;
/** 1=墓碑(C11) */
@com.fasterxml.jackson.annotation.JsonProperty("is_deleted")
private Integer isDeleted;
}
/** 行内 {@code meta} 的两件(闭集,消费方按 {@code submitter_id==0} 认匿名行) */
@Getter
@Setter
public static class AnswerRowMeta {
@com.fasterxml.jackson.annotation.JsonProperty("ip")
private String ip;
@com.fasterxml.jackson.annotation.JsonProperty("ua")
private String ua;
}
} }
@@ -11,7 +11,7 @@ import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。 * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 v2 §2.3 两档 + §9,方案 §5.5)。
* *
* <p>三条对外纪律:</p> * <p>三条对外纪律:</p>
* <ul> * <ul>
@@ -178,6 +178,16 @@ public final class ReadModels {
private String code; private String code;
private Set<DataScope> scopes; private Set<DataScope> scopes;
private Integer userId; private Integer userId;
/** 会话学校(契约 v2 §2.3:ALL 档租户限定变体的谓词基准,SELF 档不消费) */
private Long viewerSchId;
/**
* ALL 档是否为<b>租户限定变体</b>(契约 v2 D9:校管理员 × 平台级表单 {@code sch_id=0} 只看
* 本校提交行,谓词 {@code submitter_sch_id=会话校})。
*
* <p>它是 ALL 档内部的收窄,不是第三个范围值——所以放在规格位上而不是枚举里;
* 超管/创建人/form_admin 命中时恒 false(全量)。</p>
*/
private boolean platformScopedAll;
private String status; private String status;
private List<FormFilterOperators.Spec> filters; private List<FormFilterOperators.Spec> filters;
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */ /** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
@@ -0,0 +1,30 @@
package com.yunhai.formcenter.model;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 本校组织树(选人弹窗数据源)的下发形状(T10 方案 §5.3 末段:同 notice 形态,天然限本校树)。
*
* <p>字段名对齐前端移植自 notice 的 {@code UserCheckModal.vue}:它按 {@code isParent} 判组/人
* (组=文件夹、叶子=人员),按 {@code children} 递归,{@code id/name} 直接进树。</p>
*/
public final class SchoolModels {
private SchoolModels() {
}
@Getter
@Setter
public static class Node {
/** 组节点=UserBase 组 id;人节点=UserBase 用户主键(即 admin 两表的 user_id) */
private Integer id;
private String name;
/** true=组(可展开),false=人员叶子 */
private Boolean isParent;
private List<Node> children = new ArrayList<>();
}
}
@@ -0,0 +1,57 @@
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.Lock;
import javax.persistence.LockModeType;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 投递行仓库(outbox,TASK_2 B.3/B.4)。
*
* <p>只提供扫描/定位/分页三类取数,<b>不提供任何删除方法</b>:投递行是审计资产,
* 终态(DEAD/GRANT_REVOKED)留在表里供对账与人工处置,与答卷"无物理删除"同一条纪律
* ({@code AnswerRepository} 类头)。</p>
*/
public interface AnswerDeliveryRepository extends JpaRepository<AnswerDelivery, Long> {
/** 回调/发送失败按同一行串行裁决,避免 return 与 ACK 各读旧状态后互相覆盖。 */
@Override
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AnswerDelivery> findById(Long id);
/** 回执端点定位(eventId 全局唯一,另三键做逐字复核) */
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AnswerDelivery> findByEventId(String eventId);
/** 同 revision 同消费方是否已有行(入队幂等;正常由 uk_delivery_revision 兜底) */
Optional<AnswerDelivery> findByAnswerUuidAndAnswerRevisionAndConsumerAppKeyAndConsumerSchoolId(
String answerUuid, Long answerRevision, String consumerAppKey, Long consumerSchoolId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<AnswerDelivery> findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(String answerUuid, String consumerAppKey);
/** 到点待投(PENDING 首投与 FAILED 退避重试同一条队列) */
@Query("select d from AnswerDelivery d where d.state in ('PENDING', 'FAILED') "
+ "and (d.nextRetryAt is null or d.nextRetryAt <= :now) order by d.id asc")
List<AnswerDelivery> findDue(@Param("now") LocalDateTime now, Pageable pageable);
/** 失败/死信分页({@code failedPage},TASK_3 对账用) */
@Query("select d from AnswerDelivery d where d.consumerAppKey = :appKey "
+ "and d.consumerSchoolId = :schoolId and d.state in ('FAILED', 'DEAD') order by d.id desc")
Page<AnswerDelivery> findFailedForConsumer(@Param("appKey") String appKey,
@Param("schoolId") Long schoolId, Pageable pageable);
/** 该答卷是否还有未落定投递(墓碑/回放前的存在性判定) */
boolean existsByAnswerUuidAndStateIn(String answerUuid, List<String> states);
@Query("select count(d) from AnswerDelivery d where d.state = :state")
long countByState(@Param("state") String state);
}
@@ -0,0 +1,35 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.AnswerReconcileManifestRow;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 任务 manifest 读取(TASK_3)。
*
* <p>只读接口 + 批量插入:<b>没有 update、没有 delete</b>。清单一旦物化就是这一轮对账的事实源,
* 分页读它、缺席判定也读它;{@code answer} 表此后怎么变都不影响本轮结论(这正是它存在的理由)。</p>
*/
public interface AnswerReconcileManifestRepository extends JpaRepository<AnswerReconcileManifestRow, Long> {
/** 清单分页:游标=上一个 answerUuid(32 位十六进制),不是页码、不是库内 id */
@Query("select m from AnswerReconcileManifestRow m where m.taskId = :taskId and m.answerUuid > :cursor "
+ "order by m.answerUuid asc")
List<AnswerReconcileManifestRow> findPage(@Param("taskId") Long taskId, @Param("cursor") String cursor,
Pageable pageable);
Optional<AnswerReconcileManifestRow> findByTaskIdAndAnswerUuid(Long taskId, String answerUuid);
boolean existsByTaskIdAndAnswerUuid(Long taskId, String answerUuid);
long countByTaskId(Long taskId);
/** 断点续作物化前的自检:这一轮已经写了多少行(checkpoint 丢失时的兜底口径) */
@Query("select coalesce(max(m.seqNo), -1) from AnswerReconcileManifestRow m where m.taskId = :taskId")
int maxSeqNo(@Param("taskId") Long taskId);
}
@@ -0,0 +1,44 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.FormConsumerGrant;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 对账专用只读仓库(TASK_3 改动点 2/6:"摘要读取用本任务新增只读 Repository/投影")。
*
* <p>存在的理由有两层:</p>
* <ul>
* <li><b>不给 TASK_2 的文件加快捷查询</b>——{@code AnswerRepository} 是投递与数据面共用的门面,
* 对账要的"按 uuid 升序整表扫"只在物化时用一个瞬间,塞进共用门面就是污染;</li>
* <li><b>订阅清单的枚举口径归本任务</b>——TASK_1 的 {@code FormConsumerGrantRepository} 只提供
* 按表单取订阅(投递路径的形态),调度要的是"全校全表单的生效订阅游标",这是本任务的读取面,
* 条件仍与 TASK_1 逐字一致({@code enabled=1 and subscribeAllowed=1})。</li>
* </ul>
*
* <p>返回 {@code Answer} 实体而不是投影:清单摘要里的 hash 必须由
* {@code AnswerSnapshotService#contentHash} 按 {@code fc.canon.v1} 现算,而它要完整值文档。
* 取数一律带 keyset 游标与 {@code Pageable} 上限,<b>任何方法都不允许无界拉全表</b>。</p>
*/
public interface AnswerReconcileReadRepository extends JpaRepository<Answer, Long> {
/** 物化取数:按 (表单, 消费租户域) 的 uuid 升序 keyset,墓碑一并出(清单含缺席事实) */
@Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schoolId "
+ "and a.uuid > :cursor order by a.uuid asc")
List<Answer> findScopeAfterUuid(@Param("formUuid") String formUuid, @Param("schoolId") Long schoolId,
@Param("cursor") String cursor, Pageable pageable);
/** 建任务前的资源预算:范围内答卷总数(含墓碑),超限即拒绝建任务而不是当场载表 */
@Query("select count(a.id) from Answer a where a.formUuid = :formUuid and a.schId = :schoolId")
long countScope(@Param("formUuid") String formUuid, @Param("schoolId") Long schoolId);
/** 调度枚举生效订阅(游标=id,升序):判据与 TASK_1 的 findEnabledSubscriptions 同一条 */
@Query("select g from FormConsumerGrant g where g.enabled = 1 and g.subscribeAllowed = 1 "
+ "and g.readAllowed = 1 and g.id > :cursor order by g.id asc")
List<FormConsumerGrant> findLiveSubscriptionsAfterId(@Param("cursor") Long cursor, Pageable pageable);
}
@@ -0,0 +1,36 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.AnswerReconcileResult;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 对账结果仓库(TASK_3)。
*
* <p>一 attempt 一行、{@code (task_id, attempt)} 唯一:重复回报由服务层读回原行按幂等吸收,
* 不做覆盖更新;比最新 attempt 更旧的回报由 {@link #findLatest} 的返回值判方向后拒绝推进。</p>
*/
public interface AnswerReconcileResultRepository extends JpaRepository<AnswerReconcileResult, Long> {
Optional<AnswerReconcileResult> findByTaskIdAndAttempt(Long taskId, Integer attempt);
Optional<AnswerReconcileResult> findFirstByTaskIdOrderByIdDesc(Long taskId);
@Query("select max(r.attempt) from AnswerReconcileResult r where r.taskId = :taskId")
Integer maxAttempt(@Param("taskId") Long taskId);
@Query("select r from AnswerReconcileResult r where r.taskId = :taskId order by r.attempt asc")
List<AnswerReconcileResult> findByTask(@Param("taskId") Long taskId);
/** 失败明细分页(管理面查看与重放入口的取数) */
@Query("select r from AnswerReconcileResult r where r.consumerAppKey = :appKey "
+ "and r.consumerSchoolId = :schoolId and r.state in ('PARTIAL', 'FAILED') order by r.id desc")
Page<AnswerReconcileResult> findUnresolved(@Param("appKey") String appKey,
@Param("schoolId") Long schoolId, Pageable pageable);
}
@@ -0,0 +1,162 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.AnswerReconcileTask;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import javax.persistence.LockModeType;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 对账任务仓库(TASK_3)。
*
* <p>三件事在这里定型:<b>①领取用条件更新而不是"读—改—写"</b>(两个扫描线程同时看到同一条
* QUEUED 时,只有一个能刷新租约,另一个更新 0 行即退出——这就是任务书要求的单并发);
* <b>②同一坐标只允许一个活跃任务</b>({@code countActiveAt} 是它的判据);
* <b>③没有任何删除方法</b>:任务、清单、结果都是审计资产,终态留表供复盘。</p>
*/
public interface AnswerReconcileTaskRepository extends JpaRepository<AnswerReconcileTask, Long> {
Optional<AnswerReconcileTask> findByTaskUuid(String taskUuid);
Optional<AnswerReconcileTask> findByIdempotencyKey(String idempotencyKey);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from AnswerReconcileTask t where t.id = :id")
Optional<AnswerReconcileTask> findByIdForUpdate(@Param("id") Long id);
/** 待物化(建任务后由扫描异步物化,全量人工任务因此不会在请求线程里载表) */
@Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' "
+ "and t.manifestState = 'PENDING' and t.cancelRequested = 0 order by t.id asc")
List<AnswerReconcileTask> findPendingMaterialization(Pageable pageable);
/** 物化中断后需续跑的半成品(checkpoint 非空,仍在跑的那条租约未到期不会被领走) */
@Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' "
+ "and t.manifestState = 'MATERIALIZING' and (t.leaseUntil is null or t.leaseUntil <= :now) "
+ "and t.cancelRequested = 0 order by t.id asc")
List<AnswerReconcileTask> findStaleMaterialization(@Param("now") LocalDateTime now, Pageable pageable);
/** 清单已完整、到点可派发(首派与退避重派同一条队列) */
@Query("select t from AnswerReconcileTask t where t.state = 'QUEUED' and t.manifestComplete = 1 "
+ "and t.cancelRequested = 0 and (t.nextDispatchAt is null or t.nextDispatchAt <= :now) "
+ "order by t.id asc")
List<AnswerReconcileTask> findDispatchable(@Param("now") LocalDateTime now, Pageable pageable);
/** 租约超期:按 attempt+1 有限退避重派(业务结果仍按 taskId/attempt 幂等) */
@Query("select t from AnswerReconcileTask t where t.state in ('DISPATCHED', 'RUNNING') "
+ "and t.leaseUntil is not null and t.leaseUntil <= :now and t.cancelRequested = 0 "
+ "order by t.id asc")
List<AnswerReconcileTask> findExpiredLease(@Param("now") LocalDateTime now, Pageable pageable);
/** 同坐标活跃任务数(单并发建任务判据) */
@Query("select count(t) from AnswerReconcileTask t where t.consumerAppKey = :appKey "
+ "and t.consumerSchoolId = :schoolId and t.formUuid = :formUuid "
+ "and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')")
long countActiveAt(@Param("appKey") String appKey, @Param("schoolId") Long schoolId,
@Param("formUuid") String formUuid);
/**
* 同一消费方当前在途(已派发或执行中)的任务数——全量人工任务"单并发"的判据:
* 一个消费者一次只跑一轮对账,避免两个 Worker 同时扫同一份本地集合互相踩检查点。
*/
@Query("select count(t) from AnswerReconcileTask t where t.consumerAppKey = :appKey "
+ "and t.consumerSchoolId = :schoolId and t.state in ('DISPATCHED', 'RUNNING') "
+ "and t.id <> :exceptId")
long countInFlightForConsumer(@Param("appKey") String appKey, @Param("schoolId") Long schoolId,
@Param("exceptId") Long exceptId);
/** 管理面列表:按消费域(超管可看全部,校级只看本域) */
@Query("select t from AnswerReconcileTask t where (:appKey is null or t.consumerAppKey = :appKey) "
+ "and (:schoolId is null or t.consumerSchoolId = :schoolId) "
+ "and (:state is null or t.state = :state) order by t.id desc")
Page<AnswerReconcileTask> findForAdmin(@Param("appKey") String appKey, @Param("schoolId") Long schoolId,
@Param("state") String state, Pageable pageable);
// ===== 条件更新(并发只落一次)=====
/** 领取物化权:PENDING 或超期的 MATERIALIZING 才能被领走 */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.manifestState = 'MATERIALIZING', t.leaseUntil = :leaseUntil "
+ "where t.id = :id and t.state = 'QUEUED' and t.cancelRequested = 0 "
+ "and (t.manifestState = 'PENDING' or ((t.manifestState = 'MATERIALIZING') "
+ "and (t.leaseUntil is null or t.leaseUntil <= :now)))")
int claimMaterialization(@Param("id") Long id, @Param("now") LocalDateTime now,
@Param("leaseUntil") LocalDateTime leaseUntil);
/** 领取派发权:只有租约到期或还没派过的行会被领走 */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.state = 'DISPATCHED', t.attempt = t.attempt + 1, "
+ "t.leaseUntil = :leaseUntil, t.dispatchedAt = :now, t.nextDispatchAt = null "
+ "where t.id = :id and t.state = 'QUEUED' and t.manifestComplete = 1 and t.cancelRequested = 0 "
+ "and (t.nextDispatchAt is null or t.nextDispatchAt <= :now)")
int claimForDispatch(@Param("id") Long id, @Param("now") LocalDateTime now,
@Param("leaseUntil") LocalDateTime leaseUntil);
/** 派发失败后退避重排(不占用 attempt 之外的次数预算) */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.state = 'QUEUED', t.nextDispatchAt = :nextAt, "
+ "t.leaseUntil = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ "where t.id = :id and t.state = 'DISPATCHED'")
int rescheduleDispatch(@Param("id") Long id, @Param("nextAt") LocalDateTime nextAt,
@Param("errorCode") String errorCode, @Param("detail") String detail);
/** 租约定期续租(消费端进度回报会带上 taskId/attempt,中心据此刷新) */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.leaseUntil = :leaseUntil, t.state = 'RUNNING' "
+ "where t.id = :id and t.state in ('DISPATCHED', 'RUNNING')")
int markRunning(@Param("id") Long id, @Param("leaseUntil") LocalDateTime leaseUntil);
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.leaseUntil = :leaseUntil, t.state = 'RUNNING' "
+ "where t.id = :id and t.attempt = :attempt and t.cancelRequested = 0 "
+ "and t.state in ('DISPATCHED', 'RUNNING')")
int renewRunning(@Param("id") Long id, @Param("attempt") Integer attempt,
@Param("leaseUntil") LocalDateTime leaseUntil);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from AnswerReconcileTask t where t.taskUuid = :uuid")
Optional<AnswerReconcileTask> lockByTaskUuid(@Param("uuid") String uuid);
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.state = :newState, t.finishedAt = :now, "
+ "t.leaseUntil = null, t.nextDispatchAt = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ "where t.id = :id and t.attempt = :attempt and t.cancelRequested = 0 "
+ "and t.state in ('DISPATCHED', 'RUNNING')")
int finishAttempt(@Param("id") Long id, @Param("attempt") Integer attempt,
@Param("newState") String newState, @Param("now") LocalDateTime now,
@Param("errorCode") String errorCode, @Param("detail") String detail);
/** 终态推进:只有非终态行会被改写(重复回报幂等,不由这里保证——这里只防倒退覆盖) */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.state = :newState, t.finishedAt = :now, "
+ "t.leaseUntil = null, t.nextDispatchAt = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ "where t.id = :id and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')")
int advanceTerminal(@Param("id") Long id, @Param("newState") String newState,
@Param("now") LocalDateTime now, @Param("errorCode") String errorCode,
@Param("detail") String detail);
/** 租约超期重排:DISPATCHED/RUNNING 回到 QUEUED 等下一次退避派发 */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.state = 'QUEUED', t.nextDispatchAt = :nextAt, "
+ "t.leaseUntil = null, t.errorCode = :errorCode, t.errorDetail = :detail "
+ "where t.id = :id and t.state in ('DISPATCHED', 'RUNNING') and (t.leaseUntil is null "
+ "or t.leaseUntil <= :now)")
int requeueExpiredLease(@Param("id") Long id, @Param("now") LocalDateTime now,
@Param("nextAt") LocalDateTime nextAt, @Param("errorCode") String errorCode,
@Param("detail") String detail);
/** 取消收敛:非终态一律落 CANCELLED(终态行更新 0 行,取消动作因此是幂等的) */
@Modifying(clearAutomatically = true)
@Query("update AnswerReconcileTask t set t.state = 'CANCELLED', t.cancelRequested = 1, "
+ "t.finishedAt = :now, t.leaseUntil = null, t.nextDispatchAt = null, "
+ "t.errorCode = 'CANCELLED', t.errorDetail = :detail "
+ "where t.id = :id and t.state in ('QUEUED', 'DISPATCHED', 'RUNNING')")
int markCancelled(@Param("id") Long id, @Param("now") LocalDateTime now, @Param("detail") String detail);
}
@@ -3,6 +3,7 @@ package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Answer;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -25,6 +26,28 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
Optional<Answer> findByUuidAndSchId(String uuid, Long schId); Optional<Answer> findByUuidAndSchId(String uuid, Long schId);
/**
* 快照修订号行级 CAS(v2 投递契约 §2,TASK_2 B.1 的并发控制落点)。
*
* <p>{@code UPDATE} 自身拿行锁:并发改同一条答卷时,后到的事务在这里被挡到前一个提交,
* 回来后 {@code WHERE answer_revision = :expected} 不再匹配 → 返回 0 行 → 调用方抛异常整事务回滚。
* 与 outbox 的 {@code uk_delivery_revision} 是同一条不变式的两道闸:CAS 挡"取到同一个号",
* 唯一键挡"已经写出去的两行同版不同内容"。</p>
*
* <p>必须在改动实体的 {@code answerRevision} 字段<b>之前</b>调用:JPQL 批量更新绕过脏检查,
* 先改字段会让自动 flush 把新号写进去、CAS 再也匹配不上旧号(自我拒绝)。</p>
*/
@Modifying
@Query("update Answer a set a.answerRevision = :next where a.id = :id and a.answerRevision = :expected")
int compareAndSetRevision(@Param("id") Long id, @Param("expected") Long expected, @Param("next") Long next);
/**
* 按 (域, uuid 集合) 批量定位(T14 守卫:数据面入口要按答卷反查它所属的表单才能判 ALL 档)。
* 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。
*/
@Query("select a from Answer a where a.schId = :schId and a.uuid in (:uuids)")
List<Answer> findBySchIdAndUuidIn(@Param("schId") Long schId, @Param("uuids") Collection<String> uuids);
/** /**
* 两段式列表的第二段(方案 §5.5 铁律①):<b>按当页 id 批量取整文档</b>。 * 两段式列表的第二段(方案 §5.5 铁律①):<b>按当页 id 批量取整文档</b>。
* 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json}; * 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json};
@@ -61,6 +84,31 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
/** 我的提交段(T7)/ "我提交的"视图(T8):按 submitter 过滤本人答卷,按 id 降序(最新在前) */ /** 我的提交段(T7)/ "我提交的"视图(T8):按 submitter 过滤本人答卷,按 id 降序(最新在前) */
List<Answer> findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId); List<Answer> findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId);
/**
* oapi v2 拉取·冻结表单的 append-only 窗口(契约 v2 §6.1/C9/C11,T17):
* 按 id 升序推进、<b>不过滤 is_deleted</b>(墓碑照常下发)。
*
* <p>带 {@code sch_id} 是 oapi 面既有那条"本层只保证不跨校取数"的不变式(应用签名不替代谁能看);
* 与 {@link #findLiveByFormUuidAfterId} 的区别正是软删行——那条是补建索引用的活数据,这条是对外事实流。</p>
*/
@Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schId and a.id > :cursor "
+ "order by a.id asc")
List<Answer> findPullAfterId(@Param("formUuid") String formUuid, @Param("schId") Long schId,
@Param("cursor") Long cursor, Pageable pageable);
/**
* oapi v2 拉取·非冻结表单的 updated 窗口(§6.1/C9/C11):复合推进键
* {@code (update_time, answer_uuid)}——同刻多行也能定序且不漏不重(重放安全,
* 边界行至多重复投递一次,由消费方幂等 upsert 吸收)。<b>同样含墓碑</b>:
* 软删走实体 save({@code @PreUpdate} 必刷 update_time),所以删除动作就在这个窗口里(V70 钉住)。
*/
@Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schId "
+ "and (a.updateTime > :time or (a.updateTime = :time and a.uuid > :uuid)) "
+ "order by a.updateTime asc, a.uuid asc")
List<Answer> findPullUpdatedSince(@Param("formUuid") String formUuid, @Param("schId") Long schId,
@Param("time") java.time.LocalDateTime time, @Param("uuid") String uuid,
Pageable pageable);
/** /**
* "关于我的"视图(T8,V18):按 subject 过滤——别人<b>代我填</b>的答卷也在这里 * "关于我的"视图(T8,V18):按 subject 过滤——别人<b>代我填</b>的答卷也在这里
* (方案 §6.3:谁提交与关于谁是两个角色,学生看到的是"关于我"的那几条,不限于自己填的)。 * (方案 §6.3:谁提交与关于谁是两个角色,学生看到的是"关于我"的那几条,不限于自己填的)。
@@ -0,0 +1,25 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.AppRegistry;
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;
/**
* app 注册表仓库(T16)。查询一律带租户域与软删过滤,唯一例外是
* {@link #findByAppCode}——它要连软删行一起看,否则"code 曾被注册"会被撞成库内唯一键 500
* 而不是一句可诊断的拒绝(P125)。
*/
public interface AppRegistryRepository extends JpaRepository<AppRegistry, Long> {
/** 按 code 定位(含软删行;{@code uk_app_code} 全库唯一,至多一行) */
Optional<AppRegistry> findByAppCode(String appCode);
/** 清单取域:本校 ∪ 平台级(sch_id=0)的未删行——契约 C17"按会话域" */
@Query("select a from AppRegistry a where a.schId in (:schIds) and a.isDeleted = 0 order by a.id asc")
List<AppRegistry> findLiveInDomain(@Param("schIds") Collection<Long> schIds);
}
@@ -0,0 +1,28 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormAdmin;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 单表单管理员仓库(T10)。规模=每表单个位数,列表一律不分页(方案 §4)。
*
* <p>{@code uk_form_admin(form_uuid, user_id)} 是重复添加的幂等兜底;{@code idx_form_admin_user}
* 供"我管理的表单"这类按人的反查(v2 入口,本轮不消费,方案 §8 已登记)。</p>
*/
public interface FormAdminRepository extends JpaRepository<FormAdmin, Long> {
List<FormAdmin> findByFormUuidOrderByIdAsc(String formUuid);
Optional<FormAdmin> findByIdAndFormUuid(Long id, String formUuid);
boolean existsByFormUuidAndUserId(String formUuid, Integer userId);
/**
* 按人反查"我被授权了哪些表单"(T14 的 managed 清单,C3)。
* {@code idx_form_admin_user} 就是为这条留的(方案 §8 登记的 v2 入口,本批兑现)。
*/
List<FormAdmin> findByUserId(Integer userId);
}
@@ -0,0 +1,40 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormConsumerGrant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* 消费授权仓库(v3 §2)。三元唯一键就是单点定位入口;批量查询服务目录端点,
* 免得逐张表回库(一校已发布表单量级个位数到几百,同 {@link FormRepository#findLiveBySch} 的量级论证)。
*
* <p>撤销用 {@code enabled=0} 表达,所以<b>判授权的查询必须带 enabled 条件</b>——
* 漏掉它就会让已撤销的授权继续生效(fail-open),这条是本仓库唯一不能写错的地方。</p>
*/
public interface FormConsumerGrantRepository extends JpaRepository<FormConsumerGrant, Long> {
Optional<FormConsumerGrant> findByFormUuidAndConsumerSchoolIdAndConsumerAppKey(
String formUuid, Long consumerSchoolId, String consumerAppKey);
/** 管理端 grant 清单(含已撤销行:撤销后仍要看得见、可重申) */
@Query("select g from FormConsumerGrant g where g.formUuid = :formUuid order by g.id asc")
List<FormConsumerGrant> findLiveByForm(@Param("formUuid") String formUuid);
/** 订阅面取授权:只认生效且开了订阅的行(v3 §7,TASK_3 投递判据同源) */
@Query("select g from FormConsumerGrant g where g.formUuid = :formUuid "
+ "and g.consumerSchoolId = :schoolId and g.enabled = 1 and g.subscribeAllowed = 1")
List<FormConsumerGrant> findEnabledSubscriptions(@Param("formUuid") String formUuid,
@Param("schoolId") Long schoolId);
/** 目录端点批量取某消费方在某校的生效授权(读判定用) */
@Query("select g from FormConsumerGrant g where g.formUuid in (:uuids) "
+ "and g.consumerSchoolId = :schoolId and g.consumerAppKey = :appKey and g.enabled = 1")
List<FormConsumerGrant> findForConsumer(@Param("uuids") Collection<String> uuids,
@Param("schoolId") Long schoolId,
@Param("appKey") String appKey);
}
@@ -0,0 +1,24 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormOwnership;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* owner 绑定仓库(v3 §2)。判定一律按 uuid 取,不带租户条件——租户域复核在
* {@code FormConsumerAccessService} 里做(同 {@link FormRepository#findByUuid} 的分工:
* uuid 是全局坐标,"这条绑定属不属于当前域"是应用层规则)。
*/
public interface FormOwnershipRepository extends JpaRepository<FormOwnership, Long> {
Optional<FormOwnership> findByFormUuid(String formUuid);
/** 目录端点的批量取绑定(避免逐张表 N+1 查询) */
@Query("select o from FormOwnership o where o.formUuid in (:uuids)")
List<FormOwnership> findByFormUuidIn(@Param("uuids") Collection<String> uuids);
}
@@ -43,4 +43,47 @@ public interface FormRepository extends JpaRepository<Form, Long> {
List<Form> findResolveCandidates(@Param("code") String code, List<Form> findResolveCandidates(@Param("code") String code,
@Param("appCode") String appCode, @Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds); @Param("schIds") Collection<Long> schIds);
/**
* 匿名面取域(契约 v2 §3.2/C6):按 {@code (app_code, code)} 取<b>已发布且未软删</b>的头表,
* <b>不带任何租户条件</b>——匿名请求没有会话校,{@code (会话校 ∪ 平台级)} 那条取域规则无从成立。
*
* <p>{@code audience} 是否含 {@code ANONYMOUS} 留在服务侧判(逗号码串在 SQL 里没有可移植的
* 集合算子,P10 保守口径),跨校同 code 的歧义也在服务侧 fail-closed(问题单 P119)。</p>
*/
@Query("select f from Form f where f.code = :code and f.appCode = :appCode "
+ "and f.isDeleted = 0 and f.status = 1")
List<Form> findAnonCandidates(@Param("code") String code, @Param("appCode") String appCode);
/**
* 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里<b>已发布且未软删</b>的表单头表。
*
* <p>{@code status} 写死在 SQL 而 app_code 过滤留在应用层,同 {@link #findLiveBySch} 的口径:
* 生命周期是这一把查询的语义(草稿/停用压根不是候选),而 app_code 是可选筛项,
* 可空参数在 MySQL 上的类型推断有坑(P10)。遮蔽去重与入口层过滤都在服务侧。</p>
*/
@Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 and f.status = 1")
List<Form> findPublishedInDomain(@Param("schIds") Collection<Long> schIds);
/**
* 「我管理的」清单取域(契约 v2 C3):域内<b>未软删</b>的表单,不限生命周期——
* 草稿与停用行都要在管理支路里出现(创建人要能继续改自己没发布的表)。
* 归属判定(创建人 / form_admin)在服务侧做,理由同 {@link #findLiveBySch}:
* 可空参数与 OR 型 JPQL 在 MySQL 上有类型推断坑(P10),且一校的表单量级是个位数到几百。
*/
@Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 order by f.updateTime desc, f.id desc")
List<Form> findLiveInDomain(@Param("schIds") Collection<Long> schIds);
/** 按 uuid 批量取头表(T14 守卫:由答卷反查所属表单以判 ALL 档;uk_form_uuid 唯一) */
@Query("select f from Form f where f.uuid in (:uuids)")
List<Form> findByUuidIn(@Param("uuids") Collection<String> uuids);
/**
* 某个 app_code 名下还有多少张<b>未软删</b>表单(T16 删除保护的"在用"判据,§5.2)。
*
* <p>不带 sch_id 条件是<b>有意的</b>:{@code uk_app_code} 是全库唯一,code 被删掉就等于对所有人
* 都不可用,那么"他校还在用这个 code"同样构成在用——按窄的一边判(fail-closed,同 P117 的取向)。</p>
*/
@Query("select count(f) from Form f where f.appCode = :appCode and f.isDeleted = 0")
long countLiveByAppCode(@Param("appCode") String appCode);
} }
@@ -0,0 +1,24 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.SchoolAdmin;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 校级表单管理员仓库(T10)。规模=每校个位数,列表一律不分页(方案 §4)。
*
* <p>{@code uk_school_admin(sch_id, user_id)} 是重复添加的幂等兜底;行按 id 升序回显,
* 与前端"序号/姓名/操作"的列表形态同序。</p>
*/
public interface SchoolAdminRepository extends JpaRepository<SchoolAdmin, Long> {
List<SchoolAdmin> findBySchIdOrderByIdAsc(Long schId);
Optional<SchoolAdmin> findByIdAndSchId(Long id, Long schId);
boolean existsBySchIdAndUserId(Long schId, Integer userId);
long countBySchId(Long schId);
}
@@ -0,0 +1,25 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.SubmissionContext;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 可信提交上下文仓库(TASK_2 A.2/A.3)。
*
* <p>定位方式只有三种,各自对应契约里的一条不变式:</p>
* <ul>
* <li>{@code (consumerAppKey, intentId)}——同意图重试必须回到<b>同一</b>上下文(uk_ctx_intent);</li>
* <li>{@code tokenDigest}——填报页 redeem,只按摘要查,明文不入库(uk_ctx_token_digest);</li>
* <li>{@code answerUuid}——收据端点按答卷反查意图(uk_ctx_answer 保证一对一)。</li>
* </ul>
*/
public interface SubmissionContextRepository extends JpaRepository<SubmissionContext, Long> {
Optional<SubmissionContext> findByConsumerAppKeyAndIntentId(String consumerAppKey, String intentId);
Optional<SubmissionContext> findByTokenDigest(String tokenDigest);
Optional<SubmissionContext> findByAnswerUuid(String answerUuid);
}
@@ -1,35 +0,0 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>关于我</b>({@code subject_id}=会话用户)。
*
* <p>与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是
* <em>关于自己</em>的数据,<b>包含老师代填的那一条</b>——老师代填时 {@code submitter_id} 是老师、
* {@code subject_id} 是学生,按本范围才可见(V18)。</p>
*/
@Component
public class AboutMeScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.ABOUT_ME;
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
return cb.equal(root.get("subjectId"), userId);
}
}
@@ -0,0 +1,191 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormAdmin;
import com.yunhai.formcenter.entity.SchoolAdmin;
import com.yunhai.formcenter.model.AdminModels;
import com.yunhai.formcenter.repository.FormAdminRepository;
import com.yunhai.formcenter.repository.SchoolAdminRepository;
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.List;
/**
* 管理员两表的读写(T10 方案 §4/§5.3):{@link AdminScopeSource} 的生产实现 + 管理面 CRUD。
*
* <p><b>三条写侧纪律</b>:</p>
* <ol>
* <li><b>幂等</b>:{@code uk_school_admin(sch_id,user_id)} / {@code uk_form_admin(form_uuid,user_id)}
* 是重复添加的兜底——已存在的行直接跳过,不报错也不重复建行(沿登记脚本的幂等哲学);</li>
* <li><b>不信任前端</b>:选人弹窗的名单来自 {@code GetSchoolAll()}(天然限本校树),但落库前仍逐个
* 走 {@link SubjectDirectory#existsInSchool} 复核归属——传别人的 userId 一律拒(方案 §5.3 末段);</li>
* <li><b>防呆</b>:校级管理员删到剩 0 拒绝。没有校级管理员的学校仍能用矩阵默认口径读数据,
* 但"删空最后一员"会让此后无人能进角色设置页加人(D6 的自管理闭环断掉),所以宁可按业务规则拒。</li>
* </ol>
*
* <p>行删除一律按 {@code (id, 本次操作域)} 定位({@code findByIdAndSchId}/{@code findByIdAndFormUuid}),
* 跨校/跨表单的 id 探测与"不存在"同一口径回答;{@code user_name} 是展示快照,权威始终在 UserBase。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AdminDomainService implements AdminScopeSource {
/** 与 school_admin.user_name / form_admin.user_name 的列宽一致(超长按 PARAM_ERROR 拒,不给 500) */
private static final int USER_NAME_MAX = 64;
private final SchoolAdminRepository schoolAdminRepository;
private final FormAdminRepository formAdminRepository;
private final SubjectDirectory subjectDirectory;
// ==================== 只读判定(AdminScopeSource) ====================
@Override
public boolean isSchoolAdmin(Long schId, Integer userId) {
return schId != null && userId != null && schoolAdminRepository.existsBySchIdAndUserId(schId, userId);
}
@Override
public boolean isFormAdmin(String formUuid, Integer userId) {
if (formUuid == null || formUuid.trim().isEmpty() || userId == null) {
return false;
}
return formAdminRepository.existsByFormUuidAndUserId(formUuid.trim(), userId);
}
// ==================== 校级表单管理员(守 /admin/app) ====================
public List<AdminModels.AdminView> listSchoolAdmins(Long schId) {
List<AdminModels.AdminView> views = new ArrayList<>();
for (SchoolAdmin row : schoolAdminRepository.findBySchIdOrderByIdAsc(schId)) {
views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
}
return views;
}
/** 批量添加(幂等);返回添加后的全量列表,前端免去第二次请求 */
@Transactional
public List<AdminModels.AdminView> addSchoolAdmins(Long schId, List<AdminModels.AdminUserItem> users) {
for (AdminModels.AdminUserItem item : requireUsers(users)) {
requireBelongsToSchool(item, schId);
if (schoolAdminRepository.existsBySchIdAndUserId(schId, item.getUserId())) {
continue;
}
SchoolAdmin row = new SchoolAdmin();
row.setSchId(schId);
row.setUserId(item.getUserId());
row.setUserName(item.getUserName());
// saveAndFlush:同批里重复的人(前端理论上不会给出,但接口不拒这种形态)也要被 exists 检查看到,
// 不能让 uk 撞键把一次正常的幂等添加变成 500
schoolAdminRepository.saveAndFlush(row);
log.info("[角色设置] 新增校级表单管理员 schId={} userId={} name={}",
schId, item.getUserId(), item.getUserName());
}
return listSchoolAdmins(schId);
}
@Transactional
public List<AdminModels.AdminView> removeSchoolAdmin(Long schId, Long id) {
SchoolAdmin row = schoolAdminRepository.findByIdAndSchId(id, schId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "校级管理员记录不存在:" + id));
if (schoolAdminRepository.countBySchId(schId) <= 1) {
throw new BizException(ErrorCode.CUSTOM, "本校至少保留一名校级表单管理员,最后一名不可删除");
}
schoolAdminRepository.delete(row);
log.info("[角色设置] 删除校级表单管理员 schId={} userId={} rowId={}", schId, row.getUserId(), row.getId());
return listSchoolAdmins(schId);
}
// ==================== 单表单管理员(守 /admin/forms) ====================
public List<AdminModels.AdminView> listFormAdmins(Form form) {
List<AdminModels.AdminView> views = new ArrayList<>();
for (FormAdmin row : formAdminRepository.findByFormUuidOrderByIdAsc(form.getUuid())) {
views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
}
return views;
}
/**
* 批量添加(幂等)。
*
* @param domain 本次操作域(守卫返回的租户域)——基础表单的 {@code form.schId=0} 不是可复核的学校,
* 归属复核按 <b>域</b> 走:校级操作人=会话校(因此只能把自己学校的人加进基础表单的管理员),
* 超管平台态(域=0)在此被 {@link SubjectDirectory} 判为"不属于本校"而拒,
* 需要给基础表单授权时显式带 {@code schId=某校}。取舍记在 T10 报告。
*/
@Transactional
public List<AdminModels.AdminView> addFormAdmins(Form form, Long domain,
List<AdminModels.AdminUserItem> users) {
for (AdminModels.AdminUserItem item : requireUsers(users)) {
requireBelongsToSchool(item, domain);
if (formAdminRepository.existsByFormUuidAndUserId(form.getUuid(), item.getUserId())) {
continue;
}
FormAdmin row = new FormAdmin();
row.setFormUuid(form.getUuid());
row.setSchId(form.getSchId());
row.setUserId(item.getUserId());
row.setUserName(item.getUserName());
formAdminRepository.saveAndFlush(row);
log.info("[表单管理员] 新增 form={} userId={} name={}",
form.getCode(), item.getUserId(), item.getUserName());
}
return listFormAdmins(form);
}
@Transactional
public List<AdminModels.AdminView> removeFormAdmin(Form form, Long id) {
FormAdmin row = formAdminRepository.findByIdAndFormUuid(id, form.getUuid())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单管理员记录不存在:" + id));
formAdminRepository.delete(row);
log.info("[表单管理员] 删除 form={} userId={}", form.getCode(), row.getUserId());
return listFormAdmins(form);
}
// ==================== 内部 ====================
/** 请求形态校验:非空名单 + 每人有 userId、有姓名快照(列宽内) */
private List<AdminModels.AdminUserItem> requireUsers(List<AdminModels.AdminUserItem> users) {
if (users == null || users.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一人(users 为空)");
}
for (AdminModels.AdminUserItem item : users) {
if (item == null || item.getUserId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少 userId");
}
String name = item.getUserName() == null ? null : item.getUserName().trim();
if (name == null || name.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少姓名:userId=" + item.getUserId());
}
if (name.length() > USER_NAME_MAX) {
throw new BizException(ErrorCode.PARAM_ERROR,
"姓名快照过长(≤" + USER_NAME_MAX + "):userId=" + item.getUserId());
}
item.setUserName(name);
}
return users;
}
/** 落库前的归属复核(方案 §5.3:不信任前端,跨校传参一律拒) */
private void requireBelongsToSchool(AdminModels.AdminUserItem item, Long schId) {
if (!subjectDirectory.existsInSchool(item.getUserId(), schId)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该用户不属于本次管理域,不能加为管理员:userId=" + item.getUserId() + " schId=" + schId);
}
}
private static AdminModels.AdminView toView(Long id, Integer userId, String userName) {
AdminModels.AdminView view = new AdminModels.AdminView();
view.setId(id);
view.setUserId(userId);
view.setUserName(userName);
return view;
}
}
@@ -0,0 +1,21 @@
package com.yunhai.formcenter.service;
/**
* 管理员两表的<b>只读判定端口</b>(T10 方案 §5.1):范围解析与守卫都从这里问"这个人在不在
* {@code school_admin} / {@code form_admin} 里",生产实现是 {@code AdminDomainService}(直查本库两表)。
*
* <p><b>为什么做成端口</b>:{@code DataScopeService} 与 {@code FormAccessPolicy} 都只消费这两个布尔判定,
* 加宽一侧(比如将来按人反查"我管理的表单",方案 §8 的 v2 入口)不该把仓库与写侧逻辑渗进解析器;
* 同 {@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。</p>
*
* <p><b>口径</b>:判定只认会话身份({@code (schId, userId)} / {@code (formUuid, userId)}),
* 查不到即 false(fail-closed)——残留行(教师离校)因会话失效而永远走不到这里,方案 §8。</p>
*/
public interface AdminScopeSource {
/** 该用户是否 {@code schId} 的校级表单管理员 */
boolean isSchoolAdmin(Long schId, Integer userId);
/** 该用户是否这张表单(按 uuid)的单表单管理员 */
boolean isFormAdmin(String formUuid, Integer userId);
}
@@ -2,13 +2,29 @@ package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope; import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root; import javax.persistence.criteria.Root;
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */ /**
* 范围实现器:<b>ALL</b>——该表单全部行(契约 v2 §2.3)。
*
* <p>持有者=创建人 ∪ form_admin ∪ 校管理员(∪ 超管)。两种落点:</p>
* <ul>
* <li><b>全量 ALL</b>:不加行级条件(超管、平台表单的创建人、本校表单的授权者);</li>
* <li><b>租户限定变体</b>(D9):校管理员/form_admin 面对<b>平台级表单</b>({@code sch_id=0})时,
* 只给本校提交行——谓词 {@code submitter_sch_id=会话校}。平台表单是跨校资产,
* 授权不跨过租户边界(T10 D2=B 加列的动因,本批原样承继);给不出会话校一律拒绝,
* 不静默降成"无学校条件"(那等于把本校范围读成全平台)。</li>
* </ul>
*
* <p>{@code submitter_sch_id} 为 NULL 的存量行天然不匹配等值谓词=fail-closed(P84 口径不回退)。</p>
*/
@Component @Component
public class AllScopeImplementor implements AnswerScopeImplementor { public class AllScopeImplementor implements AnswerScopeImplementor {
@@ -18,12 +34,13 @@ public class AllScopeImplementor implements AnswerScopeImplementor {
} }
@Override @Override
public boolean selectable() { public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
return true; if (!q.isPlatformScopedAll()) {
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
return cb.conjunction(); return cb.conjunction();
} }
if (q.getViewerSchId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "ALL 档的租户限定变体需要会话学校身份");
}
return cb.equal(root.get("submitterSchId"), q.getViewerSchId());
}
} }
@@ -0,0 +1,178 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* 匿名提交的 cookie 防重(契约 v2 C7/§3.2,T15)。
*
* <p><b>cookie 值就是服务端签发的时间戳</b>:{@code {issuedAtEpochSecond}.{hex(HMAC-SHA256(code.issuedAt))}}。
* 校验只看签名与时间戳两件事,因此<b>不需要任何服务端会话状态</b>(一期不引 Redis、不建表):</p>
* <ul>
* <li>签名不对(伪造、篡改时间戳、把别的表单的 cookie 拿来用)→ 视为无 cookie,放行。
* "伪造无效"的口径是<b>伪造值既不能延长也不能绕过窗口</b>,而不是"伪造要被判成攻击":这道机制是
* 节流阀不是身份认证,删掉 cookie 同样能绕,把匿名提交当身份来鉴是错的
* (契约 §3.2 边界明示只承诺"同域部署下的防重");</li>
* <li>签名对且 {@code now < issuedAt + 窗口} → 命中防重,返回剩余秒数
* (调用侧按 CUSTOM(5) 回答,msg 含剩余分钟)。</li>
* </ul>
*
* <p><b>密钥复用 {@code YH_APP_AUTH_APP_SECRET}</b>(C7 明令"不新增配置项")。未配置时匿名提交整体
* 拒绝而不是"关掉防重继续收"——防重是这张表唯一的节流(限次对匿名不适用,作废-11),
* 静默放行等于把表单交给任意脚本刷。</p>
*
* <p><b>cookie 只在提交成功(非幂等重放)时重新签发</b>,重放不续窗口:窗口是"两次真提交之间"的间隔,
* 双击重试落在同一张答卷上,不该把它拉长。</p>
*/
@Component
public class AnonReplayGuard {
/** cookie 名前缀 + 表单 code(一表单一 cookie,多表单互不干扰,C7) */
public static final String COOKIE_PREFIX = "fc_anon_";
/** {@code form.anon_cookie_minutes} 缺省窗口(DDL 默认值同为 10,实体异常时用这一个数兜住) */
public static final int DEFAULT_WINDOW_MINUTES = 10;
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final char[] HEX = "0123456789abcdef".toCharArray();
private final String secret;
private final java.time.Clock clock;
@org.springframework.beans.factory.annotation.Autowired
public AnonReplayGuard(@Value("${yh.app-auth.app-secret:}") String secret) {
this(secret, java.time.Clock.systemUTC());
}
AnonReplayGuard(String secret, java.time.Clock clock) {
this.secret = secret == null ? "" : secret.trim();
this.clock = java.util.Objects.requireNonNull(clock);
}
public static String cookieName(String formCode) {
return COOKIE_PREFIX + formCode;
}
/** 防重窗口分钟数(null/非正数一律回落默认 10,不让一个脏配置变成"防重关闭") */
public static int windowMinutes(Integer configured) {
return configured == null || configured < 1 ? DEFAULT_WINDOW_MINUTES : configured;
}
/**
* 提交成功后要下发的 cookie 值。
*
* @throws BizException 密钥未配置({@code YH_APP_AUTH_APP_SECRET} 为空)
*/
public String issue(String formCode) {
requireSecret();
return issuedAt(formCode, clock.instant().getEpochSecond());
}
/**
* 防重窗口判定。
*
* @param cookieValue 请求里带来的 cookie 值(可为 null)
* @return 还需等待的秒数;0=可以提交
*/
public long throttleSeconds(String formCode, Integer configuredWindowMinutes, String cookieValue) {
if (cookieValue == null || cookieValue.trim().isEmpty()) {
return 0L;
}
if (secret.isEmpty()) {
// 有 cookie 却无密钥可验:既不能判"放行"(那等于伪造值也放行),也不能判"命中"。
// 与 issue() 同口径——密钥缺失时匿名通道不可用,配置问题要在提交前暴露而不是静默降级。
requireSecret();
}
int window = windowMinutes(configuredWindowMinutes) * 60;
Long issuedAt = parse(formCode, cookieValue.trim());
if (issuedAt == null) {
return 0L;
}
long remaining = issuedAt + window - clock.instant().getEpochSecond();
return remaining > 0 ? remaining : 0L;
}
/**
* 写 {@code Set-Cookie}。手写头而不是 {@code response.addCookie}:Servlet 的 {@code Cookie} 没有
* {@code SameSite} 位,而嵌入态(同站 iframe)需要显式给出一个值——留空即浏览器默认 Lax,
* 跨域 iframe 会被丢 cookie(契约 §3.2"边界明示"那条,本实现按默认口径原样写入消费方通告)。
*
* @param contextPath 请求上下文路径(生产 {@code /form-center},本地为空)→ cookie Path,C7
*/
public void appendCookie(HttpServletResponse response, String formCode, String value,
Integer configuredWindowMinutes, String contextPath) {
int window = windowMinutes(configuredWindowMinutes) * 60;
String path = contextPath == null || contextPath.isEmpty() ? "/" : contextPath;
StringBuilder header = new StringBuilder(128)
.append(cookieName(formCode)).append('=').append(value)
.append("; Path=").append(path)
.append("; Max-Age=").append(window)
.append("; HttpOnly; SameSite=Lax");
response.addHeader("Set-Cookie", header.toString());
}
/** 命中防重时的剩余分钟文案(向上取整、至少 1 分钟——"请 0 分钟后再试"等于没回答,C7) */
public static int remainingMinutes(long remainingSeconds) {
return (int) Math.max(1L, (remainingSeconds + 59L) / 60L);
}
// ==================== 内部 ====================
/** 时间戳与签名拆分 + 常量时间比对;非法形态或签名不符返回 null(=按无 cookie 处理) */
private Long parse(String formCode, String value) {
int dot = value.lastIndexOf('.');
if (dot <= 0 || dot == value.length() - 1) {
return null;
}
long issuedAt;
try {
issuedAt = Long.parseLong(value.substring(0, dot));
} catch (NumberFormatException e) {
return null;
}
String signature = value.substring(dot + 1);
if (!MessageDigest.isEqual(sign(formCode, issuedAt).getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
return null;
}
// 未来时间戳按"刚提交过"处理(窗口不越过配置值):签名有效说明它是本机签发的,
// 时钟抖动/机器时间偏快都不该把窗口拉长,更不该反过来把过去的票据顶成"刚刚"
return Math.min(issuedAt, clock.instant().getEpochSecond());
}
/** 签发指定制表时刻的票据值(窗口边界用例要用"过去的时间戳",包内可见即可,不对外暴露) */
String issuedAt(String formCode, long issuedAtSeconds) {
return issuedAtSeconds + "." + sign(formCode, issuedAtSeconds);
}
private String sign(String formCode, long issuedAtSeconds) {
byte[] raw;
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
raw = mac.doFinal((formCode + "." + issuedAtSeconds).getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException | java.security.InvalidKeyException e) {
throw new IllegalStateException("匿名防重签名失败:" + e.getMessage(), e);
}
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(HEX[(b >> 4) & 0xF]).append(HEX[b & 0xF]);
}
return sb.toString();
}
private void requireSecret() {
if (secret.isEmpty()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
"匿名通道不可用:防重密钥未配置(请注入 YH_APP_AUTH_APP_SECRET)");
}
}
}
@@ -9,6 +9,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.entity.StatusLog; import com.yunhai.formcenter.entity.StatusLog;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository; import com.yunhai.formcenter.repository.AnswerOpLogRepository;
@@ -44,10 +45,12 @@ import java.util.List;
* 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。</li> * 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。</li>
* </ul> * </ul>
* *
* <p><b>两层权限都过</b>(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard}, * <p><b>两层权限都过</b>(P30 的收紧点,T4 落地;契约 v2 C4② 换了入口判据):控制器先按
* 决定"能不能用数据管理接口"),每条答卷再过一次<b>行级范围</b>——判据是四条读出路径共用的 * <b>该表单 ALL 档</b>把关({@code FormAdminGuard#requireDataByAnswer(s)},决定"能不能用数据管理接口"),
* 每条答卷再过一次<b>行级范围</b>——判据是四条读出路径共用的
* {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此<b>列表里看不见的答卷, * {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此<b>列表里看不见的答卷,
* 编辑/软删/改状态也做不了</b>:教师即便被授权了数据管理模块行,也只能动自己范围内的行。</p> * 编辑/软删/改状态也做不了</b>:ALL 档入口进来之后,行级仍按同一份范围规格判——"契约 v2 §2.3
* 的租户限定变体"下,本校之外的行(含 {@code submitter_sch_id=NULL} 的存量行)照改不动。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -69,6 +72,9 @@ public class AnswerAdminService {
private final AnswerReadService readService; private final AnswerReadService readService;
private final AnswerScopeQueryBuilder queryBuilder; private final AnswerScopeQueryBuilder queryBuilder;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final FcEventPublisher events;
/** v2 快照投递:取号与 outbox 入队都在每条独立事务内(TASK_2 B.1,含改状态与批量出口) */
private final AnswerDeliveryService deliveries;
// ==================== 单条 ==================== // ==================== 单条 ====================
@@ -120,6 +126,12 @@ public class AnswerAdminService {
Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId()); Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId());
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion()); FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
String schemaJson = pinned.versionRow().getSchemaJson(); String schemaJson = pinned.versionRow().getSchemaJson();
// 冻结开关(契约 v2 §4.2/V61):与本人编辑侧同一条判据、同一份文案族——
// "原件是档案"这件事不能只在提交人那侧生效,否则管理员改值就绕开了冻结语义
if (Integer.valueOf(1).equals(pinned.form().getFrozen())) {
throw new BizException(ErrorCode.CUSTOM,
"该表单已开启提交后禁编辑,修正请撤回后重新提交:" + pinned.form().getCode());
}
// P33:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校—— // P33:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校——
// 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。 // 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。
@@ -134,9 +146,13 @@ public class AnswerAdminService {
assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson); assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson);
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
// 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变 // 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变
indexService.rebuild(answer, schemaJson); indexService.rebuild(answer, schemaJson);
// 事件:非冻结编辑(冻结在上面就拒了,走不到这里)= updated(契约 v2 §6.2 发布点之一)
events.answerUpdated(answer);
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(), opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(),
req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")" req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")"
: "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary()); : "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary());
@@ -146,6 +162,7 @@ public class AnswerAdminService {
private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) { private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) {
Answer answer = requireManageableAnswer(operator, schId, answerUuid); Answer answer = requireManageableAnswer(operator, schId, answerUuid);
answer.setIsDeleted(1); answer.setIsDeleted(1);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
indexService.clear(answer.getId()); indexService.clear(answer.getId());
// 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。 // 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。
@@ -155,6 +172,10 @@ public class AnswerAdminService {
FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId())); FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId()));
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_SOFT_DELETE : AnswerOpLog.OP_SOFT_DELETE, opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_SOFT_DELETE : AnswerOpLog.OP_SOFT_DELETE,
operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)"); 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, private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req,
@@ -175,6 +196,7 @@ public class AnswerAdminService {
} }
String oldStatus = answer.getStatus(); String oldStatus = answer.getStatus();
answer.setStatus(status); answer.setStatus(status);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
StatusLog entry = new StatusLog(); StatusLog entry = new StatusLog();
entry.setAnswerId(answer.getId()); entry.setAnswerId(answer.getId());
@@ -185,6 +207,9 @@ public class AnswerAdminService {
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS, opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS,
operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
+ "(无转移规则校验)"); + "(无转移规则校验)");
// 任务书点名的漏项防线:status 是对外快照成员,改状态必须同样取号并入队(updated),
// 否则"审核已通过"这类事实对消费方永远不可见,而 outbox 看上去还是完整的
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
} }
// ==================== 辅助 ==================== // ==================== 辅助 ====================
@@ -204,7 +229,7 @@ public class AnswerAdminService {
new ReadModels.QueryRequest()); new ReadModels.QueryRequest());
if (!queryBuilder.visible(scope, answer.getUuid())) { if (!queryBuilder.visible(scope, answer.getUuid())) {
throw new BizException(ErrorCode.FORBIDDEN, throw new BizException(ErrorCode.FORBIDDEN,
"该答卷不在你的数据范围内(契约 §8 矩阵),不能执行管理动作:" + answerUuid); "该答卷不在你的数据范围内(契约 v2 §2.3 两档),不能执行管理动作:" + answerUuid);
} }
return answer; return answer;
} }
@@ -0,0 +1,188 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* 答卷快照的规范化与哈希(answer-delivery-v2 §4,TASK_2 B.1)。
*
* <p><b>为什么要有版本化规范化,而不是直接对 {@code valuesJson} 原文求哈希</b>:投递侧要在
* "同一 revision 只能对应一份 hash"这条不变式上落库唯一键,消费侧要用<b>同一算法</b>复算来判
* "同版异 hash=冲突待核验"。成员顺序取决于来源(值文档是客户端 JSON 原文、身份列是服务端拼装),
* 不规范化就会让同一份数据产出两种字节串——那等于把幂等判据建在随机数上。</p>
*
* <p><b>{@code fc.canon.v1} 规则闭集</b>(要改语义就升算法号,不许原地改):</p>
* <ol>
* <li><b>对象</b>:键按 <b>UTF-16 代码元字典序</b>({@link String#compareTo})升序递归输出——
* 刻意钉比较器而不是"码点序":两端都是 JVM,同一个比较器才逐字一致;</li>
* <li><b>数组</b>:顺序保留(多选/排序是业务语义),逐元素递归;</li>
* <li><b>数字</b>:{@code decimalValue().stripTrailingZeros().toPlainString()},
* 即 {@code 100.0→100}、{@code 1E+2→100},无指数、无多余前导零;</li>
* <li><b>字符串</b>:仅转义 {@code " \ 与控制字符}({@code \b\f\n\r\t} 用短转义,
* 其余 C0 用"反斜杠 + u + 00 + 两位大写十六进制",非 ASCII(含中文)<b>原样输出</b>,
* 哈希按 UTF-8 取字节;</li>
* <li><b>空值策略</b>:<b>显式 null 成员保留</b>(缺失键与 {@code "k":null} 是两种事实,
* 合并会丢"字段被清空"这次变化);{@code missing}/{@code null} 节点视为不存在的成员;</li>
* <li><b>排除项</b>:快照顶层的 {@code intent} 成员不参与哈希——它是"仅对该 consumer 可见"的
* 关联块,同 revision 会因为消费方不同而不同,留着就与不变式①直接矛盾;
* 投递时间/重试计数/消费端扩展列都是信封或本地列,压根不进快照。</li>
* </ol>
*/
public final class AnswerCanonicalizer {
/** 规范化算法号(信封里不回带,由 protocolVersion=fc.answer.snapshot.v2 隐含锁定) */
public static final String CANONICALIZATION_VERSION = "fc.canon.v1";
public static final String HASH_ALGORITHM = "SHA-256";
/** 规则⑥:顶层排除成员 */
private static final String EXCLUDED_TOP_LEVEL_MEMBER = "intent";
private AnswerCanonicalizer() {
}
/** 全量规范化(含 intent 块本身不参与,见规则⑥——两入口同一条剔除口径) */
public static String canonical(JsonNode node) {
StringBuilder sb = new StringBuilder(256);
write(node == null ? null : stripExcluded(node), sb);
return sb.toString();
}
/**
* 快照哈希:64 位小写十六进制 SHA-256(UTF-8 字节)。
* 消费端复算出的值与本值不等而 revision 相等 ⇒ 冲突待核验,不覆盖。
*/
public static String hashSnapshot(JsonNode snapshot) {
return sha256Hex(canonical(snapshot));
}
private static JsonNode stripExcluded(JsonNode node) {
if (node == null || !node.isObject()) {
return node;
}
ObjectNode copy = ((ObjectNode) node).deepCopy();
copy.remove(EXCLUDED_TOP_LEVEL_MEMBER);
return copy;
}
private static void write(JsonNode node, StringBuilder sb) {
if (node == null || node.isNull() || node.isMissingNode()) {
sb.append("null");
return;
}
if (node.isObject()) {
List<String> names = new ArrayList<>();
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
names.add(fields.next().getKey());
}
names.sort(String::compareTo);
sb.append('{');
boolean first = true;
for (String name : names) {
if (!first) {
sb.append(',');
}
first = false;
writeString(name, sb);
sb.append(':');
write(node.get(name), sb);
}
sb.append('}');
return;
}
if (node.isArray()) {
sb.append('[');
for (int i = 0; i < node.size(); i++) {
if (i > 0) {
sb.append(',');
}
write(node.get(i), sb);
}
sb.append(']');
return;
}
if (node.isNumber()) {
sb.append(canonicalNumber(node.decimalValue()));
return;
}
if (node.isBoolean()) {
sb.append(node.booleanValue() ? "true" : "false");
return;
}
writeString(node.asText(), sb);
}
/** 规则③:整值与小数同一条十进制通路 */
private static String canonicalNumber(BigDecimal raw) {
BigDecimal stripped = raw.stripTrailingZeros();
// scale<0 时 toPlainString 仍会给 "1E+2" 形态的补零,这里手动摊平
if (stripped.scale() < 0) {
stripped = stripped.setScale(0);
}
return stripped.toPlainString();
}
private static void writeString(String value, StringBuilder sb) {
sb.append('"');
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"':
sb.append("\\\"");
break;
case '\\':
sb.append("\\\\");
break;
case '\b':
sb.append("\\b");
break;
case '\f':
sb.append("\\f");
break;
case '\n':
sb.append("\\n");
break;
case '\r':
sb.append("\\r");
break;
case '\t':
sb.append("\\t");
break;
default:
if (c < 0x20) {
sb.append("\\u00").append(String.format("%02X", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
}
private static String sha256Hex(String input) {
MessageDigest digest;
try {
digest = MessageDigest.getInstance(HASH_ALGORITHM);
} catch (NoSuchAlgorithmException e) {
// JDK 保证有 SHA-256;真缺了就是环境坏了,不能降级成"没有哈希"继续投出去
throw new IllegalStateException("SHA-256 不可用(JVM 环境异常)", e);
}
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
hex.append(Character.forDigit(b & 0xF, 16));
}
return hex.toString();
}
}
@@ -0,0 +1,925 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
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.core.ReturnedMessage;
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.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 快照投递服务(answer-delivery-v2 §3/§5,TASK_2 B.1/B.3/B.4/B.6 + TASK_3 扩展点)。
*
* <p><b>四件事按顺序各归各位,混一条就不可靠</b>:</p>
* <ol>
* <li><b>入队=答卷事务内的一次写</b>({@link #enqueueInTransaction}):outbox 行与答卷行同生同死,
* 进程在提交后、发送前被杀也只是"晚投",不是"丢投"——这是本类与旧
* {@code FcEventPublisher}(afterCommit 才落"发"这个动作、丢了由拉取兜底)的根本分工差别,
* 任务书明令不许照搬旧形态;</li>
* <li><b>afterCommit 只唤醒扫描</b>({@link #wakeScan}):扫的是库里的行,不是内存里的闭包;</li>
* <li><b>发送前再核授权</b>({@link #publish}):行的目标列只证明"入队那刻"合法,
* 撤权后不重放旧 grant(v2 §5),但已交出去的明文副本不承诺远程收回;</li>
* <li><b>broker 收下≠消费方应用</b>:confirm 只写 {@code broker_accepted_at},
* {@code consumer_applied_at} 只能由消费方签名回执写({@link #recordApplied})。</li>
* </ol>
*
* <p>没配 broker 时本类<b>不 no-op 丢事件</b>:行留在 {@code PENDING},启动与每次扫描各记一条日志,
* 状态可用 {@code failedPage} / {@code countByState} 观察(B.4"保留待投递并可观察")。</p>
*/
@Slf4j
@Service
public class AnswerDeliveryService {
private static final DateTimeFormatter OCCURRED_AT = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final DateTimeFormatter TS = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final String HEADER_DELIVERY_ID = "fc-delivery-id";
private static final String HEADER_DELIVERY_ATTEMPT = "fc-delivery-attempt";
/** 退避序列:base×2^n,封顶 cap(broker 故障时不许热循环) */
private static final List<String> SCANNABLE_STATES =
Arrays.asList(AnswerDelivery.STATE_PENDING, AnswerDelivery.STATE_FAILED);
private final AnswerDeliveryRepository deliveryRepository;
private final AnswerRepository answerRepository;
private final AnswerSnapshotService snapshots;
private final FormConsumerAccessService accessService;
private final ObjectMapper objectMapper;
private final TransactionTemplate transactionTemplate;
private final ObjectProvider<RabbitTemplate> templates;
/** 发布方身份(服务端配置;不接受消费方申报,也不从资源 appCode 推) */
@Value("${fc.answer.delivery.source-app-key:${yh.app-auth.app-key:}}")
private String sourceAppKey;
@Value("${yh.app-auth.app-key:}")
private String signingAppKey;
@Autowired
private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient;
@Value("${spring.application.name:form-center}")
private String applicationCode;
@Value("${spring.rabbitmq.host:}")
private String host;
/** broker 配了但没开 correlated confirm:拿不到"总线收下"的证据,扫描必须拒绝发送(B.4) */
@Value("${spring.rabbitmq.publisher-confirm-type:none}")
private String confirmType;
@Value("${spring.rabbitmq.publisher-returns:false}")
private boolean publisherReturns = true;
@Value("${spring.rabbitmq.template.mandatory:false}")
private boolean mandatory = true;
@Value("${fc.answer.delivery.batch-size:50}")
private int batchSize;
@Value("${fc.answer.delivery.max-retries:8}")
private int maxRetries;
@Value("${fc.answer.delivery.backoff-base-seconds:30}")
private long backoffBaseSeconds;
@Value("${fc.answer.delivery.backoff-cap-seconds:3600}")
private long backoffCapSeconds;
private final AtomicBoolean exchangeDeclared = new AtomicBoolean(false);
private final AtomicBoolean missingMqLogged = new AtomicBoolean(false);
/** 扫描专用单线程:串行发送,避免同一行被两个线程各发一次 */
private static final ExecutorService SCAN_EXECUTOR = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "fc-answer-delivery-scan");
t.setDaemon(true);
return t;
});
@Autowired
public AnswerDeliveryService(AnswerDeliveryRepository deliveryRepository,
AnswerRepository answerRepository,
AnswerSnapshotService snapshots,
FormConsumerAccessService accessService,
ObjectMapper objectMapper,
TransactionTemplate transactionTemplate,
ObjectProvider<RabbitTemplate> templates) {
this.deliveryRepository = deliveryRepository;
this.answerRepository = answerRepository;
this.snapshots = snapshots;
this.accessService = accessService;
this.objectMapper = objectMapper;
this.transactionTemplate = transactionTemplate;
this.templates = templates;
}
@PostConstruct
public void logState() {
if (isBlank(signingAppKey) || isBlank(sourceAppKey) || !signingAppKey.trim().equals(sourceAppKey.trim())) {
throw new IllegalStateException("fc.answer.delivery.source-app-key 必须等于 yh.app-auth.app-key 签名身份");
}
sourceAppKey = sourceAppKey.trim();
if (!hostConfigured()) {
log.info("[v2投递] 未配置 RABBITMQ_HOST:outbox 仍照常落库并留在 PENDING(不丢事件,B.4)");
return;
}
if (!"correlated".equalsIgnoreCase(confirmType) || !publisherReturns || !mandatory) {
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)
&& publisherReturns && mandatory
&& templates.getIfAvailable() != null;
}
// ==================== 取号(答卷事务内) ====================
/**
* 取下一个快照修订号(行级 CAS,B.1 的并发控制)。
*
* <p>调用时机是<b>业务字段改完之后、{@code save} 之前</b>:JPQL 批量更新前 Hibernate 会先
* 自动 flush 已改字段(此时行上仍是旧号,CAS 匹配得上),随后这条 UPDATE 把号推到 next
* 并拿到行锁;并发对手在同一行上被挡到本事务提交,回来时 {@code WHERE} 不再匹配 → 0 行 → 拒。
* 顺序反过来(先改实体字段再 CAS)会造成自我拒绝。</p>
*/
public long nextRevision(Answer answer) {
if (answer == null || answer.getId() == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "取快照修订号需要已落库的答卷行");
}
long current = answer.getAnswerRevision() == null ? 0L : answer.getAnswerRevision();
long next = current + 1;
int updated = answerRepository.compareAndSetRevision(answer.getId(), current, next);
if (updated != 1) {
throw new BizException(ErrorCode.CUSTOM,
"答卷并发修改冲突(快照修订号 CAS 未命中),本次操作回滚,请重试:" + answer.getUuid());
}
answer.setAnswerRevision(next);
return next;
}
// ==================== 入队(答卷事务内) ====================
/**
* 在<b>调用方事务内</b>为该答卷的当前 revision 建逐订阅投递行。
*
* <p>无订阅的简单表单返回空列表——答卷照常提交,不创建任何消费投递记录(B.2 末句)。
* <b>HTTP-only 订阅(未开 {@code push_allowed})同样不建行</b>(2026-10-22 决策②):
* 拉取/收据/对账那条授权还在,但没有 broker 私有队列的消费方进了 outbox 就只能长期 PENDING——
* 宁不建行,也不制造积压假象;哪天它真接了 broker,把 push 翻开(grant_version 进位)即可。
* 撞 {@code uk_delivery_revision} 说明另一个事务已把同一 revision 投出去了(CAS 没挡住的极端形态),
* 整事务回滚是唯一安全处置,不做"改号重试"。</p>
*/
public int enqueueInTransaction(Answer answer, String eventType) {
if (answer == null || answer.getUuid() == null || answer.getAnswerRevision() == null) {
return 0;
}
List<SubscriptionGrant> grants =
accessService.listEnabledSubscriptions(answer.getFormUuid(), answer.getSchId());
if (grants.isEmpty()) {
return 0;
}
String contentHash = snapshots.contentHash(answer);
LocalDateTime occurredAt = answer.getUpdateTime() == null ? LocalDateTime.now() : answer.getUpdateTime();
int queued = 0;
for (SubscriptionGrant grant : grants) {
if (!grant.isPushAllowed()) {
log.info("[v2投递] HTTP-only 订阅跳过入队(未开 push_allowed,不建 outbox 行):consumer={}@{}",
grant.getConsumerAppKey(), grant.getConsumerSchoolId());
continue;
}
queued++;
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, queued, grants.size());
return queued;
}
private String writeSnapshot(Answer answer, SubscriptionGrant grant) {
Snapshot snapshot = snapshots.forConsumer(answer, grant.getConsumerAppKey(), grant.getConsumerSchoolId());
try {
return objectMapper.writeValueAsString(snapshot);
} catch (Exception e) {
throw new IllegalStateException("快照序列化失败(答卷提交必须一起失败,不能留无载荷投递行):"
+ answer.getUuid(), e);
}
}
/**
* 入队 + 唤醒(写侧唯一该调的口):{@link #enqueueInTransaction} 必须在调用方事务内,
* {@link #wakeScan} 自己识别 afterCommit,调用点不需要知道事务边界在哪。
*/
public void enqueueAndWake(Answer answer, String eventType) {
if (enqueueInTransaction(answer, eventType) > 0) {
wakeScan();
}
}
// ==================== 唤醒扫描(afterCommit 的唯一职责) ====================
/** 事务提交后唤醒一次扫描;无活动事务(单测/非事务调用)就地异步跑 */
public void wakeScan() {
if (!sendEnabled()) {
return;
}
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
SCAN_EXECUTOR.submit(AnswerDeliveryService.this::sendDueBatchQuietly);
}
});
return;
}
SCAN_EXECUTOR.submit(AnswerDeliveryService.this::sendDueBatchQuietly);
}
/** 定时/唤醒共用:一次批量,异常只记日志(扫描器不许把业务线程带崩) */
public int sendDueBatchQuietly() {
if (!sendEnabled()) {
if (hostConfigured() || !missingMqLogged.getAndSet(true)) {
missingMqLogged.set(true);
if (!hostConfigured()) {
log.info("[v2投递] 无 broker:{} 条 PENDING 保留待发(countByState 可观察)",
deliveryRepository.countByState(AnswerDelivery.STATE_PENDING));
}
}
return 0;
}
try {
return sendDueBatch();
} catch (Exception e) {
log.warn("[v2投递] 扫描异常(不重试到业务线程):{}", e.toString());
return 0;
}
}
/** 到点批量发送(confirm 异步回写状态,本方法只负责"发出去"与"发不出去就退避") */
public int sendDueBatch() {
List<AnswerDelivery> due = deliveryRepository.findDue(LocalDateTime.now(),
PageRequest.of(0, Math.max(1, batchSize)));
int sent = 0;
for (AnswerDelivery row : due) {
if (publish(row)) {
sent++;
}
}
return sent;
}
private boolean publish(AnswerDelivery row) {
SubscriptionGrant current = findLiveGrant(row);
if (current == null || row.getGrantVersion() == null
|| current.getGrantVersion() != row.getGrantVersion()) {
markTerminal(row.getId(), AnswerDelivery.STATE_GRANT_REVOKED,
current == null ? "订阅授权已失效(发布前复核)"
: "授权纪元已进位(入队 " + row.getGrantVersion() + " → 现 "
+ current.getGrantVersion() + "),不重放旧 grant");
log.warn("[v2投递] 发布前 grant 复核未过:eventId={} consumer={}@{} form={} state={}",
row.getEventId(), row.getConsumerAppKey(), row.getConsumerSchoolId(),
row.getFormUuid(), AnswerDelivery.STATE_GRANT_REVOKED);
return false;
}
RabbitTemplate template = templates.getIfAvailable();
if (template == null) {
markFailed(row, "容器内无 RabbitTemplate");
return false;
}
final String publishKey = callbackKey(row);
try {
requireSourceApplication(row);
declareExchangeOnce(template);
byte[] body = objectMapper.writeValueAsString(envelopeOf(row)).getBytes(StandardCharsets.UTF_8);
MessageProperties props = new MessageProperties();
props.setMessageId(row.getEventId());
props.setCorrelationId(publishKey);
props.setHeader(HEADER_DELIVERY_ID, String.valueOf(row.getId()));
props.setHeader(HEADER_DELIVERY_ATTEMPT, publishKey);
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(publishKey));
return true;
} catch (Exception e) {
markFailed(row, publishKey, e.toString());
log.warn("[v2投递] 发送异常,退避重试:eventId={} cause={}", row.getEventId(), e.toString());
return false;
}
}
/** routing key:显式消费应用 + 租户域(<b>不是</b>资源 appCode,B.3) */
public static String routingKey(AnswerDelivery row) {
return routingKey(row.getConsumerAppKey(), row.getConsumerSchoolId());
}
public static String routingKey(String consumerAppKey, Long consumerSchoolId) {
if (consumerAppKey == null || !consumerAppKey.matches("[A-Za-z0-9_-]+")
|| consumerSchoolId == null || consumerSchoolId <= 0) {
throw new IllegalArgumentException("INVALID_MQ_ROUTE_COORDINATE");
}
return "fc.snapshot.v2." + consumerAppKey + "." + consumerSchoolId;
}
/** Wire *AppKey fields contain credentials' keys, never application_info.id or resource appCode. */
private void requireSourceApplication(AnswerDelivery row) {
if (isBlank(sourceAppKey) || !sourceAppKey.equals(signingAppKey == null ? null : signingAppKey.trim())
|| !sourceAppKey.equals(row.getSourceAppKey())) {
throw new IllegalStateException("SOURCE_CREDENTIAL_MISMATCH");
}
JsonNode application = appRegistryClient == null ? null : appRegistryClient.getAppByCode(applicationCode);
if (application == null || !application.path("id").isIntegralNumber()
|| !application.path("id").canConvertToLong() || application.path("id").asLong() <= 0
|| !sourceAppKey.equals(application.path("appKey").asText())
|| !applicationCode.equals(application.path("appCode").asText())) {
throw new IllegalStateException("SOURCE_APPLICATION_MISMATCH");
}
}
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)。
* 发布侧同样只认开了 {@code push_allowed} 的 grant:推送被降级回 HTTP-only 的存量行
* 在这里复核不到(降级即 grant_version 进位,双保险),按撤权处置而不是照发。
*/
private SubscriptionGrant findLiveGrant(AnswerDelivery row) {
for (SubscriptionGrant grant : accessService.listEnabledSubscriptions(row.getFormUuid(),
row.getConsumerSchoolId())) {
if (row.getConsumerAppKey().equals(grant.getConsumerAppKey()) && grant.isPushAllowed()) {
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 {
String key = correlationData.getId();
// CorrelationData retains the return even if its separate callback could not persist it.
if (correlationData.getReturned() != null) {
ReturnedMessage returned = correlationData.getReturned();
onUnroutable(callbackDeliveryId(key), key, returned.getExchange() + "/"
+ returned.getRoutingKey() + " reply=" + returned.getReplyCode()
+ " " + returned.getReplyText());
return;
}
onBrokerConfirm(callbackDeliveryId(key), key, 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;
}
Object generation = returned.getMessage().getMessageProperties()
.getHeaders().get(HEADER_DELIVERY_ATTEMPT);
String key = generation == null ? legacyCallbackKey(id) : String.valueOf(generation);
onUnroutable(id, key, 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;
}
}
/** 代际来自现有 retry_count + 授权重放操作键,无新列;失败/新重放后旧回调不能改变新轮。 */
static String callbackKey(AnswerDelivery row) {
String replay = row.getLastReplayOperationId();
String digest = "";
if (replay != null && !replay.isEmpty()) {
try {
digest = Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(replay.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
return row.getId() + ":" + (row.getRetryCount() == null ? 0 : row.getRetryCount()) + ":" + digest;
}
private static String legacyCallbackKey(Long id) {
return id + ":0:"; // 旧无代际回调只可能确认原始首投,不能猜测后续重试/重放。
}
private static Long callbackDeliveryId(String key) {
int separator = key.indexOf(':');
return Long.valueOf(separator < 0 ? key : key.substring(0, separator));
}
/** 兼容首投的旧包内调用;生产新投递始终携带完整代际。 */
void onBrokerConfirm(Long deliveryId, boolean ack, String cause) {
onBrokerConfirm(deliveryId, legacyCallbackKey(deliveryId), ack, cause);
}
/** confirm 不代表可路由;同代 return 的失败事实优先,旧代 ACK/nack 不能抹掉失败或改新轮。 */
void onBrokerConfirm(Long deliveryId, String key, boolean ack, String cause) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null);
if (row == null || !callbackKey(row).equals(normalizeCallbackKey(key, deliveryId))
|| terminalDelivery(row)
|| (ack && AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState()))) {
return;
}
if (ack) {
row.setState(AnswerDelivery.STATE_BROKER_ACCEPTED);
row.setBrokerAcceptedAt(LocalDateTime.now());
row.setLastError(null);
row.setNextRetryAt(null);
} else {
row.setBrokerAcceptedAt(null);
bumpRetry(row, "broker nack: " + cause);
}
deliveryRepository.save(row);
});
}
void onUnroutable(Long deliveryId, String detail) {
onUnroutable(deliveryId, legacyCallbackKey(deliveryId), detail);
}
/** 同代 return 即使迟于 ACK 也要保留 NO_ROUTE;重复 return 代际已失效,不能重复增加退避。 */
void onUnroutable(Long deliveryId, String key, String detail) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null);
if (row == null || !callbackKey(row).equals(normalizeCallbackKey(key, deliveryId))
|| terminalDelivery(row)) {
return;
}
row.setBrokerAcceptedAt(null);
bumpRetry(row, "NO_ROUTE: 不可路由(mandatory return):" + detail);
deliveryRepository.save(row);
});
log.warn("[v2投递] 消息不可路由:id={} {}", deliveryId, detail);
}
private static String normalizeCallbackKey(String key, Long id) {
return String.valueOf(id).equals(key) ? legacyCallbackKey(id) : key;
}
private static boolean terminalDelivery(AnswerDelivery row) {
return AnswerDelivery.STATE_DEAD.equals(row.getState())
|| AnswerDelivery.STATE_GRANT_REVOKED.equals(row.getState());
}
private void markFailed(AnswerDelivery row, String error) {
markFailed(row, callbackKey(row), error);
}
private void markFailed(AnswerDelivery row, String key, String error) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery fresh = deliveryRepository.findById(row.getId()).orElse(null);
if (fresh == null || !callbackKey(fresh).equals(key) || terminalDelivery(fresh)
|| AnswerDelivery.STATE_BROKER_ACCEPTED.equals(fresh.getState())) {
return;
}
bumpRetry(fresh, error);
deliveryRepository.save(fresh);
});
}
private void markTerminal(Long id, String state, String error) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery row = deliveryRepository.findById(id).orElse(null);
if (row == null) {
return;
}
row.setState(state);
row.setLastError(truncate(error));
deliveryRepository.save(row);
});
}
/** 退避计数在<b>投递行</b>上,不在快照里:重试次数属于投递元数据,进哈希就会让同一 revision 多 hash */
private void bumpRetry(AnswerDelivery row, String error) {
int retries = (row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1;
row.setRetryCount(retries);
row.setLastError(truncate(error));
if (retries >= Math.max(1, maxRetries)) {
row.setState(AnswerDelivery.STATE_DEAD);
row.setNextRetryAt(null);
log.error("[v2投递] 有界重试耗尽→死信待处置:eventId={} consumer={} retries={} last={}",
row.getEventId(), row.getConsumerAppKey(), retries, error);
return;
}
row.setState(AnswerDelivery.STATE_FAILED);
row.setNextRetryAt(LocalDateTime.now().plusSeconds(backoffSeconds(retries)));
}
private long backoffSeconds(int retries) {
long base = Math.max(1L, backoffBaseSeconds);
long cap = Math.max(base, backoffCapSeconds);
long delay = base;
for (int i = 1; i < retries && delay < cap; i++) {
delay = Math.min(cap, delay * 2);
}
return delay;
}
// ==================== 消费方回执(consumerApplied 唯一写入点) ====================
/**
* 幂等记录消费方应用结果:按 {@code eventId + consumerAppKey + consumerSchoolId + revision}
* 逐字复核,任一不符即拒(不许用"MQ 已确认"代替业务成功,B.6)。
*/
@Transactional
public AppliedReportView recordApplied(AppliedReport report) {
if (report == null || isBlank(report.getEventId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "eventId 必填");
}
AnswerDelivery row = deliveryRepository.findByEventId(report.getEventId().trim()).orElse(null);
if (row == null || !equalsText(row.getConsumerAppKey(), report.getConsumerAppKey())
|| !equalsLong(row.getConsumerSchoolId(), report.getConsumerSchoolId())
|| !equalsLong(row.getAnswerRevision(), report.getAnswerRevision())
|| !equalsText(row.getAnswerUuid(), report.getAnswerUuid())) {
throw new BizException(ErrorCode.PARAM_ERROR, "回执与投递行不匹配(eventId/消费方/revision 任一不符即拒)");
}
String outcome = normalizeOutcome(report.getOutcome());
AppliedReportView view = new AppliedReportView();
view.setEventId(row.getEventId());
view.setBrokerAcceptedAt(row.getBrokerAcceptedAt() == null ? null : TS.format(row.getBrokerAcceptedAt()));
if (row.getConsumerAppliedAt() != null) {
// 已落定:同结论=幂等重复;不同结论=冲突,保留第一次(不覆盖,与消费端 B.5 同口径)
view.setState(row.getConsumerOutcome());
view.setConsumerAppliedAt(TS.format(row.getConsumerAppliedAt()));
view.setDuplicate(outcome.equals(row.getConsumerOutcome()));
if (!outcome.equals(row.getConsumerOutcome())) {
row.setLastError(truncate("回执结论冲突:已有 " + row.getConsumerOutcome() + ",又收到 " + outcome));
deliveryRepository.save(row);
log.warn("[v2投递] 同一 eventId 收到不同结论(保留首次):eventId={} has={} incoming={}",
row.getEventId(), row.getConsumerOutcome(), outcome);
}
return view;
}
if (AnswerDelivery.OUTCOME_APPLIED.equals(outcome)
&& !equalsText(row.getContentHash(), report.getContentHash())) {
// 自称 APPLIED 但 hash 对不上信封:按 CONFLICT 记,不把"声称成功"当成成功
outcome = AnswerDelivery.OUTCOME_CONFLICT;
}
row.setConsumerOutcome(outcome);
row.setConsumerAppliedAt(LocalDateTime.now());
if (report.getLocalRef() != null && !report.getLocalRef().trim().isEmpty()) {
row.setLastError(truncate("localRef=" + report.getLocalRef().trim()));
}
deliveryRepository.save(row);
view.setState(outcome);
view.setConsumerAppliedAt(TS.format(row.getConsumerAppliedAt()));
view.setDuplicate(Boolean.FALSE);
return view;
}
private static String normalizeOutcome(String raw) {
String value = raw == null ? "" : raw.trim().toUpperCase();
switch (value) {
case AnswerDelivery.OUTCOME_APPLIED:
case AnswerDelivery.OUTCOME_IGNORED:
case AnswerDelivery.OUTCOME_CONFLICT:
return value;
default:
throw new BizException(ErrorCode.PARAM_ERROR, "outcome 只能是 APPLIED/IGNORED/CONFLICT:" + raw);
}
}
// ==================== TASK_3 扩展点(只调用不编辑,签名即契约) ====================
/**
* 授权下的重投(对账修复入口)。状态码是<b>稳定值</b>,TASK_3 按码分支:
* <ul>
* <li>{@code NO_DELIVERY_ROW}:该消费方对这份答卷从来没有投递行(从未授权或从未订阅),
* 不新建——历史 revision 的明文快照只存在于已落库的不可变行里,无行即不可重建;</li>
* <li>{@code REVISION_MISMATCH}:期望 revision 不在已有行集内(同上,附当前最大号供运维判方向);</li>
* <li>{@code GRANT_REVOKED}:当前订阅授权不生效或纪元已进位,<b>绝不重放旧 grant</b>(v2 §5);</li>
* <li>{@code ALREADY_DELIVERED}:消费方已回执 APPLIED,重投只会被对端幂等吸收,不做无谓发送;</li>
* <li>{@code QUEUED}:行已回到 PENDING,等下一次扫描。</li>
* </ul>
*
* <p>{@code operationId} 是调用方的操作幂等键:同一 operationId 重复调用不再刷新退避,
* 直接回 {@code QUEUED}(同一次修复动作不因重试而变成多次重投)。</p>
*/
@Transactional
public DeliveryStateView replayAuthorized(ConsumerPrincipal caller, String answerUuid,
Long expectedRevision, String operationId) {
if (caller == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
}
if (isBlank(answerUuid) || expectedRevision == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerUuid 与 expectedRevision 必填");
}
DeliveryStateView view = new DeliveryStateView();
view.setAnswerRevision(expectedRevision);
List<AnswerDelivery> rows = deliveryRepository
.findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(answerUuid.trim(), caller.getAppKey());
if (rows.isEmpty()) {
view.setState(STATE_NO_DELIVERY_ROW);
view.setMessage("该消费方对这份答卷没有投递行(从未订阅或从未授权),历史快照不可重建");
return view;
}
AnswerDelivery target = null;
long maxRevision = 0L;
for (AnswerDelivery row : rows) {
maxRevision = Math.max(maxRevision, row.getAnswerRevision() == null ? 0L : row.getAnswerRevision());
if (expectedRevision.equals(row.getAnswerRevision())) {
target = row;
}
}
if (target == null) {
view.setState(STATE_REVISION_MISMATCH);
view.setMessage("期望 revision=" + expectedRevision + " 无投递行(现有最大 revision=" + maxRevision + ")");
return view;
}
view.setEventId(target.getEventId());
if (!grantStillLive(target)) {
view.setState(STATE_GRANT_REVOKED);
view.setMessage("订阅授权当前不生效或纪元已进位,不重放旧 grant");
return view;
}
if (target.getConsumerAppliedAt() != null
&& AnswerDelivery.OUTCOME_APPLIED.equals(target.getConsumerOutcome())) {
view.setState(STATE_ALREADY_DELIVERED);
view.setMessage("消费方已回执 APPLIED,无需重投");
return view;
}
if (operationId != null && operationId.equals(target.getLastReplayOperationId())
&& SCANNABLE_STATES.contains(target.getState())) {
view.setState(STATE_QUEUED);
view.setMessage("同 operationId 已在队列中(幂等吸收,不刷新退避)");
return view;
}
target.setState(AnswerDelivery.STATE_PENDING);
target.setNextRetryAt(LocalDateTime.now());
target.setLastReplayOperationId(operationId);
deliveryRepository.save(target);
view.setState(STATE_QUEUED);
view.setMessage("已回到 PENDING,等待扫描发送");
wakeScan();
return view;
}
/** 当前生效订阅复核(TASK_1 唯一判据;本类不拼第二套 ACL) */
private boolean grantStillLive(AnswerDelivery row) {
return findLiveGrant(row) != null;
}
// ==================== TASK_3 稳定状态码 ====================
public static final String STATE_NO_DELIVERY_ROW = "NO_DELIVERY_ROW";
public static final String STATE_REVISION_MISMATCH = "REVISION_MISMATCH";
public static final String STATE_GRANT_REVOKED = "GRANT_REVOKED";
public static final String STATE_ALREADY_DELIVERED = "ALREADY_DELIVERED";
public static final String STATE_QUEUED = "QUEUED";
/** 失败/死信分页(运维与 TASK_3 对账) */
public FailedDeliveryPage failedPage(ConsumerPrincipal caller, Long consumerSchoolId,
Integer page, Integer size) {
if (caller == null || consumerSchoolId == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份或租户域");
}
int pageIndex = page == null || page < 1 ? 0 : page - 1;
int pageSize = size == null || size < 1 ? 20 : Math.min(size, 100);
Page<AnswerDelivery> result = deliveryRepository.findFailedForConsumer(caller.getAppKey(),
consumerSchoolId, PageRequest.of(pageIndex, pageSize));
FailedDeliveryPage out = new FailedDeliveryPage();
out.setTotal(result.getTotalElements());
for (AnswerDelivery row : result.getContent()) {
FailedDeliveryView item = new FailedDeliveryView();
item.setEventId(row.getEventId());
item.setAnswerUuid(row.getAnswerUuid());
item.setAnswerRevision(row.getAnswerRevision());
item.setConsumerAppKey(row.getConsumerAppKey());
item.setConsumerSchoolId(row.getConsumerSchoolId());
item.setState(row.getState());
item.setRetryCount(row.getRetryCount());
item.setLastError(row.getLastError());
item.setNextRetryAt(row.getNextRetryAt() == null ? null : TS.format(row.getNextRetryAt()));
item.setBrokerAcceptedAt(row.getBrokerAcceptedAt() == null ? null : TS.format(row.getBrokerAcceptedAt()));
item.setConsumerAppliedAt(row.getConsumerAppliedAt() == null ? null : TS.format(row.getConsumerAppliedAt()));
out.getRows().add(item);
}
return out;
}
// ==================== 轻量授权核验(B.6 / 撤权边界) ====================
/**
* 按 formUuid 批量回授权快照:<b>不回查任何答案值文档</b>,只回答"这个消费方在这所学校
* 对这张表当前还能不能收订阅"。判据完全复用 TASK_1 的 {@code requireSubscribe}——
* 拒绝(含跨校、无 grant、已撤权)统一落成 {@code subscribeAllowed=false},
* 消费端据此停止写入并把缓存里的旧纪元作废。
*
* <p>单次上限 100 张:这条接口是给消费端<b>逐批</b>核验用的(契约 v2 §5 撤权边界),
* 不是给每条事件回查用的——后者会把授权判定放大成热点。</p>
*/
public List<GrantSnapshotView> grantSnapshot(ConsumerPrincipal caller, Long consumerSchoolId,
List<String> formUuids) {
if (caller == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的消费应用身份");
}
if (consumerSchoolId == null || consumerSchoolId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "consumerSchoolId 必须是真实学校(≥1)");
}
List<GrantSnapshotView> out = new ArrayList<>();
if (formUuids == null || formUuids.isEmpty()) {
return out;
}
String verifiedAt = TS.format(LocalDateTime.now());
int limit = Math.min(formUuids.size(), 100);
for (int i = 0; i < limit; i++) {
String formUuid = formUuids.get(i);
if (isBlank(formUuid)) {
continue;
}
GrantSnapshotView view = new GrantSnapshotView();
view.setFormUuid(formUuid.trim());
view.setConsumerSchoolId(consumerSchoolId);
view.setConsumerAppKey(caller.getAppKey());
view.setVerifiedAt(verifiedAt);
try {
SubscriptionGrant grant = accessService.requireSubscribe(caller, consumerSchoolId,
formUuid.trim());
view.setSubscribeAllowed(Boolean.TRUE);
view.setGrantVersion(grant.getGrantVersion());
} catch (BizException denied) {
// 跨校/无 grant/已撤权在这里本来就是同一句拒绝,对外也只给"不收"这一个事实
view.setSubscribeAllowed(Boolean.FALSE);
view.setGrantVersion(0);
}
out.add(view);
}
return out;
}
// ==================== 小工具 ====================
private static String truncate(String value) {
if (value == null) {
return null;
}
return value.length() <= 500 ? value : value.substring(0, 500);
}
private static boolean isBlank(String raw) {
return raw == null || raw.trim().isEmpty();
}
private static String trim(String raw) {
return raw == null ? null : raw.trim();
}
private static boolean equalsText(String left, String right) {
String a = trim(left);
String b = trim(right);
return a == null ? b == null : a.equals(b);
}
private static boolean equalsLong(Long left, Long right) {
return left == null ? right == null : left.equals(right);
}
}
@@ -0,0 +1,473 @@
package com.yunhai.formcenter.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ImportModels;
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.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 批量导入(契约 v2 §5.1/C13/C14,T18,验收 <b>V60/V74</b>)。
*
* <p><b>导入不是第二条写库路径</b>:每一行都拼成一次普通提交请求进 {@link AnswerService#submit}
* (V60 采集管线唯一),所以值校验、按钉住版本解释、附件归属、限次、截止、幂等、索引、事件
* 与手工填报<b>逐字同一套</b>——"导入数据与手工填的数据完全可比"(§3.4)靠的就是这个,
* 而不是导入侧再写一份校验。本类只做四件事:读 xlsx、按 label 映射列、算<b>确定性</b>幂等键、
* 逐行收集结果。</p>
*
* <p>四条纪律:</p>
* <ol>
* <li><b>坏行不静默</b>:一行的失败不影响其它行(逐行独立调用管线并独立捕获),响应里逐行给
* {@code {rowNo, ok, answerId|error}};失败原因是管线原话,导入侧不改写;</li>
* <li><b>幂等键确定性</b>(C13):{@code sha256("import:" + 表单 uuid + "|" + subject + "|" + 规范化值文档)}。
* "批次串"取<b>表单坐标</b>而不是请求级随机 uuid(那等于没有幂等),也不是文件字节摘要——
* 字节摘要会让"改一个字重传整份"把未改的行全部重插一遍。详见 {@link #idempotencyKey};
* 落地差额登记在手册 §5 <b>P128</b>;</li>
* <li><b>表头先校验再读数据</b>(C14):重名 label、当前版本没有的列、一列都不认识,都在碰到任何一行之前拒。
* "认不出的列就当没有"会让那一列的数据静默消失,比报错糟得多;</li>
* <li><b>行上限 1000,超限整批拒</b>:截断会让"导入完成"与"文件里有多少行"两个数不一致且无从发现。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerImportService {
/** C14:单次导入行上限(超限 PARAM_ERROR) */
static final int MAX_ROWS = 1000;
/** 模板第一列的表头(可空;填 UserBase 用户主键=被记录人,即代录语义) */
static final String SUBJECT_HEADER = "被记录人ID";
/** 模板第二行的说明行前缀(读侧据此跳过;用户手动删掉也不影响解析) */
private static final String NOTE_PREFIX = "说明:";
/** 导入文件大小上限:xlsx 是纯数据,与附件上传的 50MB 不同轴,不共用那个常量 */
private static final long MAX_FILE_BYTES = 10L * 1024 * 1024;
/** 幂等键长度:列宽 64,取 40 位十六进制留足余量 */
private static final int IDEMPOTENCY_KEY_LENGTH = 40;
private static final List<String> MULTI_OPTION_TYPES = Arrays.asList("checkbox", "multiselect");
private static final Set<String> UNSUPPORTED_TYPES = new HashSet<>(Arrays.asList("upload", "image"));
private final FormDefService defService;
private final FormDictService dictService;
private final AnswerService answerService;
// ==================== 模板(C14 的下载件) ====================
/**
* 生成导入模板:表头=当前发布版本的字段 label(前面固定一列"被记录人ID"),第二行是给填表人看的说明。
*
* <p>列序取<b>当前发布版本</b>的字段序(与导出同源那条规则)——导入的行会钉在这个版本上,
* 模板给旧版本的列会让每一行都撞上"未知字段"。</p>
*/
public void template(Long domainSchId, String formUuid, OutputStream out) {
Form form = defService.readDomainForm(domainSchId, formUuid);
Map<String, JsonNode> fields = publishedFields(form);
requireUniqueLabels(fields);
EasyExcel.write(out).head(headOf(fields)).sheet("导入").doWrite(new ArrayList<>());
}
// ==================== 导入 ====================
/**
* 逐行导入。
*
* @param domainSchId 已由控制器过 {@code requireData}(该表单 ALL 档)的租户域
*/
public ImportModels.Result importAnswers(BoaUser user, Long domainSchId, String formUuid, MultipartFile file) {
byte[] bytes = requireFile(file);
Form form = defService.readDomainForm(domainSchId, formUuid);
Map<String, JsonNode> fields = publishedFields(form);
List<SheetRow> rows = readRows(bytes);
if (rows.size() - 1 > MAX_ROWS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"单次导入行数上限 " + MAX_ROWS + " 行(当前 " + (rows.size() - 1) + " 行),请拆分文件");
}
Map<Integer, String> columnByKey = mapColumns(rows.get(0).cells, fields);
ImportModels.Result result = new ImportModels.Result();
List<ImportModels.RowResult> items = new ArrayList<>();
int ok = 0;
for (SheetRow row : rows.subList(1, rows.size())) {
ImportModels.RowResult item = new ImportModels.RowResult();
item.setRowNo(row.rowNo);
try {
AnswerModels.SubmitView view = answerService.submit(user,
requestOf(form, row, fields, columnByKey));
item.setOk(true);
item.setAnswerId(view.getAnswerId());
ok++;
} catch (BizException e) {
item.setOk(false);
item.setError(e.getMessage());
} catch (RuntimeException e) {
// 单行的意外异常不能带倒整批(与批量软删/批量改状态同一条"逐条独立"的既有口径)
item.setOk(false);
item.setError("导入该行时服务内部异常:" + e.getClass().getSimpleName());
log.warn("[导入] 第 {} 行异常", row.rowNo, e);
}
items.add(item);
}
result.setRows(items);
result.setOkCount(ok);
result.setFailCount(items.size() - ok);
log.info("[导入] form={}/{} 数据行={} 成功={} 失败={}",
form.getAppCode(), form.getCode(), items.size(), ok, result.getFailCount());
return result;
}
// ==================== 行 → 提交请求 ====================
private AnswerModels.SubmitRequest requestOf(Form form, SheetRow row, Map<String, JsonNode> fields,
Map<Integer, String> columnByKey) {
AnswerModels.SubmitRequest req = new AnswerModels.SubmitRequest();
req.setFormCode(form.getCode());
req.setAppCode(form.getAppCode());
req.setSubjectId(subjectOf(row));
req.setValues(valuesOf(row, fields, columnByKey));
req.setIdempotencyKey(idempotencyKey(form.getUuid(), req.getSubjectId(), req.getValues()));
return req;
}
private static Integer subjectOf(SheetRow row) {
String raw = trim(row.cells.get(0));
if (raw == null || raw.isEmpty()) {
return null;
}
try {
// Excel 的数字单元格可能带小数尾巴("10133.0"),按整数取
return Integer.valueOf(new BigDecimal(raw).toBigInteger().toString());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "被记录人ID 必须是整数:\"" + raw + "\"");
}
}
private ObjectNode valuesOf(SheetRow row, Map<String, JsonNode> fields, Map<Integer, String> columnByKey) {
ObjectNode values = FormSchemaValidator.mapper().createObjectNode();
for (Map.Entry<Integer, String> column : columnByKey.entrySet()) {
String key = column.getValue();
JsonNode field = fields.get(key);
String raw = trim(row.cells.get(column.getKey()));
if (raw == null || raw.isEmpty()) {
continue;
}
String type = field == null ? "" : field.path("type").asText("");
if (UNSUPPORTED_TYPES.contains(type)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段「" + labelOf(field, key) + "」是附件类(" + type + "),批量导入不支持上传,"
+ "请逐条填写(" + key + ")");
}
values.set(key, valueNode(raw, type));
}
return values;
}
/**
* 单元格文本 → 值节点。<b>只做最小的类型转换</b>:能转成数字就转、多选取成数组,其余一律留字符串——
* 格式对不对由提交管线里那一份 {@code FormValueValidator} 判。数字格转不动时也原样给字符串,
* 让校验器说出与手工填报同一句"必须是数字",而不是导入侧编一条。
*/
private static JsonNode valueNode(String raw, String type) {
if ("number".equals(type)) {
try {
return FormSchemaValidator.mapper().getNodeFactory().numberNode(new BigDecimal(raw));
} catch (NumberFormatException e) {
return FormSchemaValidator.mapper().getNodeFactory().textNode(raw);
}
}
if (MULTI_OPTION_TYPES.contains(type)) {
ArrayNode array = FormSchemaValidator.mapper().createArrayNode();
for (String token : raw.split("[、,,]")) {
String value = token.trim();
if (!value.isEmpty()) {
array.add(value);
}
}
return array;
}
return FormSchemaValidator.mapper().getNodeFactory().textNode(raw);
}
// ==================== 表头 ====================
/** 表头两行:第一行=label(解析按它映射字段),第二行=说明(读侧按前缀跳过) */
private static List<List<String>> headOf(Map<String, JsonNode> fields) {
List<List<String>> head = new ArrayList<>();
head.add(Arrays.asList(SUBJECT_HEADER,
NOTE_PREFIX + "填 UserBase 用户ID;留空=以你本人为被记录人(代录需该表单 ALL 档)"));
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
head.add(Arrays.asList(labelOf(entry.getValue(), entry.getKey()), hintOf(entry.getValue(), entry.getKey())));
}
return head;
}
private static String hintOf(JsonNode field, String key) {
StringBuilder hint = new StringBuilder(NOTE_PREFIX);
hint.append(field.path("required").asBoolean(false) ? "必填;" : "选填;");
String type = field.path("type").asText("");
if ("number".equals(type)) {
hint.append("数字");
} else if (MULTI_OPTION_TYPES.contains(type)) {
hint.append("多选,多个值用、分隔");
} else if ("select".equals(type) || "radio".equals(type)) {
hint.append("候选值之一:").append(optionsOf(field));
} else if ("date".equals(type)) {
hint.append("日期 yyyy-MM-dd");
} else if ("datetime".equals(type)) {
hint.append("时间 yyyy-MM-dd HH:mm:ss");
} else if (UNSUPPORTED_TYPES.contains(type)) {
hint.append("附件类,导入不支持,请留空");
} else {
hint.append("文本");
}
return hint.toString();
}
private static String optionsOf(JsonNode field) {
JsonNode options = field.path("options");
if (!options.isArray()) {
return "(见表单定义)";
}
List<String> values = new ArrayList<>();
for (JsonNode option : options) {
values.add(option.path("value").asText(option.path("label").asText("?")));
}
return String.join("、", values);
}
private static Map<String, Integer> countLabels(Map<String, JsonNode> fields) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
counts.merge(labelOf(entry.getValue(), entry.getKey()), 1, Integer::sum);
}
return counts;
}
/**
* label 唯一性(C14"重名 label 拒绝并提示先改 label"):模板与解析都按 label 映射列,
* 重名就等于"这一列到底是哪个字段"没有答案。<b>模板生成时同样先判</b>——
* 生成一份带歧义的模板再在导入时报错,不如在下载这一步就把设计器的问题点名。
*
* <p>另一件保留名:字段不能就叫 {@code 被记录人ID},那一列是模板的固定第一列(代录入口)。</p>
*/
private static void requireUniqueLabels(Map<String, JsonNode> fields) {
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
String label = labelOf(entry.getValue(), entry.getKey());
if (SUBJECT_HEADER.equals(label)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段 label 不能叫「" + SUBJECT_HEADER + "」(它是模板的固定第一列)");
}
if (countLabels(fields).get(label) > 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段 label「" + label + "」重复,模板与解析都按 label 映射,请先在设计器改 label 再导入");
}
}
}
private static String labelOf(JsonNode field, String key) {
String label = field == null ? null : trim(field.path("label").asText(null));
return label == null || label.isEmpty() ? key : label;
}
/**
* 表头 → {@code 列号 → 字段 key}(C14"表头按 label 映射字段")。
*
* <p>三道检查都在<b>读数据之前</b>:① 字段 label 有重复(模板与解析都按 label 映射,重复即二义,
* 提示先改 label);② 有列名在当前发布版本里不存在(模板过期或手改表头);③ 一列都不认识。
* ②若按"忽略那一列"处理,那一列的数据会静默消失。</p>
*/
private static Map<Integer, String> mapColumns(Map<Integer, String> head, Map<String, JsonNode> fields) {
requireUniqueLabels(fields);
Map<String, String> keyByLabel = new HashMap<>();
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
keyByLabel.put(labelOf(entry.getValue(), entry.getKey()), entry.getKey());
}
Map<Integer, String> columnByKey = new LinkedHashMap<>();
List<String> unknown = new ArrayList<>();
for (Map.Entry<Integer, String> cell : head.entrySet()) {
if (cell.getKey() == 0) {
continue;
}
String label = trim(cell.getValue());
if (label == null || label.isEmpty()) {
continue;
}
String key = keyByLabel.get(label);
if (key == null) {
unknown.add(label);
} else {
columnByKey.put(cell.getKey(), key);
}
}
if (!unknown.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"表头有当前发布版本里不存在的列:" + String.join("、", unknown)
+ "(请用本页下载的模板,或先发布含这些字段的版本)");
}
if (columnByKey.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表头没有任何可识别的字段列(不是这张表单的模板?)");
}
return columnByKey;
}
// ==================== 读文件 ====================
private static byte[] requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件为空");
}
if (file.getSize() > MAX_FILE_BYTES) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件过大(≤10MB)");
}
try {
return file.getBytes();
} catch (IOException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件读取失败:" + e.getMessage());
}
}
/**
* 读工作表:返回的列表第 0 项是表头行,其后是数据行(模板的说明行被识别后丢弃)。
*
* <p>{@code headRowNumber(1)}:EasyExcel 用第一行做表头({@link AnalysisEventListener#invokeHeadMap}),
* 其余按数据回调。行号取 {@code rowIndex + 1}——与用户在 Excel 里看到的行号一致,
* 报错时"第 7 行"要能直接指到那一格。</p>
*/
private static List<SheetRow> readRows(byte[] bytes) {
List<SheetRow> dataRows = new ArrayList<>();
Map<Integer, String> head = new LinkedHashMap<>();
try (InputStream in = new ByteArrayInputStream(bytes)) {
EasyExcel.read(in, new AnalysisEventListener<Map<Integer, String>>() {
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
head.putAll(headMap);
}
@Override
public void invoke(Map<Integer, String> row, AnalysisContext context) {
dataRows.add(new SheetRow(context.readRowHolder().getRowIndex() + 1,
new LinkedHashMap<>(row)));
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 逐行已在 invoke 里收集,无需收尾
}
}).sheet().headRowNumber(1).doRead();
} catch (IOException | RuntimeException e) {
throw new BizException(ErrorCode.PARAM_ERROR,
"导入文件解析失败(需为本页下载的 xlsx 模板):" + e.getMessage());
}
if (head.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "导入文件没有表头行");
}
if (!dataRows.isEmpty() && startsWithNote(dataRows.get(0).cells.get(0))) {
dataRows.remove(0);
}
if (dataRows.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表头之下没有数据行");
}
List<SheetRow> rows = new ArrayList<>();
rows.add(new SheetRow(1, head));
rows.addAll(dataRows);
return rows;
}
private static boolean startsWithNote(String cell) {
String value = trim(cell);
return value != null && value.startsWith(NOTE_PREFIX);
}
/** 当前发布版本的字段表;未发布直接拒(没有可校验的 schema,也没有可钉的版本) */
private Map<String, JsonNode> publishedFields(Form form) {
if (form.getCurrentVersion() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"表单尚未发布,没有可导入的字段定义:" + form.getAppCode() + "/" + form.getCode());
}
String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
.versionRow().getSchemaJson();
return FormSchemaValidator.fieldsByKeys(dictService.expandSchema(form.getSchId(), schemaJson));
}
// ==================== 幂等键 ====================
/**
* C13 的确定性幂等键:{@code sha256("import:" + 表单 uuid + "|" + subject + "|" + 规范化值文档)} 前 40 位。
*
* <p>批次串取<b>表单坐标</b>而不是文件字节摘要:字节摘要会让"改一个字重传整份文件"把未改动的那些行
* 全部当新数据插一遍(1000 行里修 1 行 = 造出 999 条重复),那是不可接受的副作用。按表单坐标后
* 键<b>只随行内容变</b>:同一张表里同样的行只落一次,改过的行才算新数据。也不含行号(中间插一行
* 不该让后续所有行的键都变掉),更不含请求级随机串(那等于没有幂等)。见手册 §5 <b>P128</b>。</p>
*
* <p>{@code "import:"} 前缀让导入产生的键与手工填报的客户端 uuid 键天然不相交({@code uk} 是库级的)。</p>
*/
private static String idempotencyKey(String formUuid, Integer subjectId, JsonNode values) {
String canonical = "import:" + formUuid + "|" + (subjectId == null ? "" : subjectId) + "|" + values;
return digest(canonical.getBytes(StandardCharsets.UTF_8)).substring(0, IDEMPOTENCY_KEY_LENGTH);
}
private static String digest(byte[] bytes) {
try {
return hex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
}
private static String hex(byte[] raw) {
StringBuilder sb = new StringBuilder(raw.length * 2);
for (byte b : raw) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
private static String trim(String raw) {
return raw == null ? null : raw.trim();
}
/** 文件侧的一行:物理行号(1 起,与用户在 Excel 里看到的行号一致)+ 列号到单元格文本 */
private static final class SheetRow {
private final int rowNo;
private final Map<Integer, String> cells;
private SheetRow(int rowNo, Map<Integer, String> cells) {
this.rowNo = rowNo;
this.cells = cells;
}
}
}
@@ -11,6 +11,7 @@ import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.model.MyModels; import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository; import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.AnswerRepository;
@@ -33,7 +34,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 数据面读服务(列表 / 详情 / 筛选候选,契约 §8/§9,方案 §5.5)。 * 数据面读服务(列表 / 详情 / 筛选候选,契约 v2 §2.3 两档范围 + §6/§9 契约,方案 §5.5)。
* *
* <p><b>与 {@link AnswerScopeQueryBuilder} 的分工</b>:本类负责"把请求翻译成查询规格" * <p><b>与 {@link AnswerScopeQueryBuilder} 的分工</b>:本类负责"把请求翻译成查询规格"
* (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。 * (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。
@@ -46,8 +47,8 @@ import java.util.Map;
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p> * 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
* *
* <p><b>末尾的"我的填写"段是另一条线,不要与上面四条混读</b>:那里的可见集来自 * <p><b>末尾的"我的填写"段是另一条线,不要与上面四条混读</b>:那里的可见集来自
* {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是契约 §8 的范围矩阵 * {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是 §2.3 的两档范围
* (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点"); * (填表人不进任何管理面也能看自己的数据,方案 §6.3"我的填写两层落点");
* 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。</p> * 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。</p>
*/ */
@Slf4j @Slf4j
@@ -66,24 +67,31 @@ public class AnswerReadService {
private final FillQuotaRepository quotaRepository; private final FillQuotaRepository quotaRepository;
private final FormFileRepository fileRepository; private final FormFileRepository fileRepository;
private final AnswerOpLogRepository opLogRepository; private final AnswerOpLogRepository opLogRepository;
private final FcEventPublisher events;
/** v2 快照投递:取号与 outbox 入队都发生在本类的事务内(TASK_2 B.1) */
private final AnswerDeliveryService deliveries;
// ==================== 规格构造(四路径共用) ==================== // ==================== 规格构造(四路径共用) ====================
/** /**
* 会话身份 + 表单 + 请求 → 读路径查询规格。 * 会话身份 + 表单 + 请求 → 读路径查询规格。
* *
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来 * <p>范围在这里定:{@link DataScopeService} 按契约 v2 §2.3 两档(ALL/SELF)解析,未登录即拒,
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的 * 不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的 {@code filterableFields} 收敛
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子), * (V3 未声明 searchable 不进候选、P28 附件无算子),传一个不在候选里的 key 直接拒——
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p> * 不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
*/ */
public ReadModels.Query scopeQuery(BoaUser user, Long domainSchId, Form form, ReadModels.QueryRequest req) { public ReadModels.Query scopeQuery(BoaUser user, Long domainSchId, Form form, ReadModels.QueryRequest req) {
ReadModels.Query query = new ReadModels.Query(); ReadModels.Query query = new ReadModels.Query();
query.setSchId(domainSchId); query.setSchId(domainSchId);
query.setAppCode(form.getAppCode()); query.setAppCode(form.getAppCode());
query.setCode(form.getCode()); query.setCode(form.getCode());
query.setScopes(scopeService.resolve(user, form)); DataScopeService.Resolved resolved = scopeService.resolve(user, form);
query.setScopes(resolved.scopes());
// 校管理员×平台级表单的 ALL 收窄成"本校提交"(D9);谓词基准=会话学校,不是查询域
query.setPlatformScopedAll(resolved.platformScopedAll());
query.setUserId(user == null ? null : user.getId()); query.setUserId(user == null ? null : user.getId());
query.setViewerSchId(FormAccessPolicy.sessionSchId(user));
query.setStatus(req == null ? null : trimToNull(req.getStatus())); query.setStatus(req == null ? null : trimToNull(req.getStatus()));
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters())); query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
return query; return query;
@@ -146,7 +154,7 @@ public class AnswerReadService {
Form form = pinned.form(); Form form = pinned.form();
ReadModels.Query query = scopeQuery(user, domainSchId, form, new ReadModels.QueryRequest()); ReadModels.Query query = scopeQuery(user, domainSchId, form, new ReadModels.QueryRequest());
if (!queryBuilder.visible(query, answer.getUuid())) { if (!queryBuilder.visible(query, answer.getUuid())) {
throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 §8 矩阵):" + answerUuid); throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 v2 §2.3 两档):" + answerUuid);
} }
ReadModels.DetailView view = new ReadModels.DetailView(); ReadModels.DetailView view = new ReadModels.DetailView();
copyInto(answer, form, view); copyInto(answer, form, view);
@@ -281,13 +289,7 @@ public class AnswerReadService {
* 普通用户只能看自己的答卷(submitter_id=会话用户),无数据管理权。 * 普通用户只能看自己的答卷(submitter_id=会话用户),无数据管理权。
*/ */
public List<ReadModels.RowView> listBySubmitter(BoaUser user, String formCode, String appCode) { public List<ReadModels.RowView> listBySubmitter(BoaUser user, String formCode, String appCode) {
if (user == null || user.getId() == null) { Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Long schId = sessionSchId(user);
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
}
String app = (appCode == null || appCode.trim().isEmpty()) ? "form-center" : appCode.trim(); String app = (appCode == null || appCode.trim().isEmpty()) ? "form-center" : appCode.trim();
// 与填报页加载同一条解析(契约 §1 resolve 是读接口唯一入口):不存在=PARAM_ERROR、未发布/停用=CUSTOM // 与填报页加载同一条解析(契约 §1 resolve 是读接口唯一入口):不存在=PARAM_ERROR、未发布/停用=CUSTOM
Form form = defService.resolvePublished(schId, app, formCode).form(); Form form = defService.resolvePublished(schId, app, formCode).form();
@@ -311,18 +313,14 @@ public class AnswerReadService {
*/ */
@Transactional @Transactional
public void softDeleteBySubmitter(BoaUser user, String answerId) { public void softDeleteBySubmitter(BoaUser user, String answerId) {
if (user == null || user.getId() == null) { Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Long schId = sessionSchId(user);
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
}
Answer answer = requireLiveAnswer(schId, answerId); Answer answer = requireLiveAnswer(schId, answerId);
if (!answer.getSubmitterId().equals(user.getId())) { if (!answer.getSubmitterId().equals(user.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷"); throw new BizException(ErrorCode.FORBIDDEN, "只能删除自己提交的答卷");
} }
answer.setIsDeleted(1); answer.setIsDeleted(1);
// 软删改变对外快照(墓碑也是变化):同事务取号 + 入队,B.1 明令不许漏这条
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
// 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填 // 与管理员软删同口径:索引行同步删、限次名额释放(V30/V32),否则删了行仍挡着本人重填
indexService.clear(answer.getId()); indexService.clear(answer.getId());
@@ -330,6 +328,9 @@ public class AnswerReadService {
defService.pinnedForData(answer.getFormUuid(), answer.getVersion()).form().getId(), defService.pinnedForData(answer.getFormUuid(), answer.getVersion()).form().getId(),
FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId())); FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId()));
opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)"); 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()); log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId());
} }
@@ -339,31 +340,46 @@ public class AnswerReadService {
*/ */
@Transactional @Transactional
public ReadModels.RowView editBySubmitter(BoaUser user, String answerId, JsonNode values, String summary) { public ReadModels.RowView editBySubmitter(BoaUser user, String answerId, JsonNode values, String summary) {
if (user == null || user.getId() == null) { Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Long schId = sessionSchId(user);
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
}
Answer answer = requireLiveAnswer(schId, answerId); Answer answer = requireLiveAnswer(schId, answerId);
if (!answer.getSubmitterId().equals(user.getId())) { if (!answer.getSubmitterId().equals(user.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "只能编辑自己提交的答卷"); throw new BizException(ErrorCode.FORBIDDEN, "只能编辑自己提交的答卷");
} }
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion()); FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
Form form = pinned.form(); Form form = pinned.form();
// 冻结开关(契约 v2 §4.2/V61):表单开启"提交后禁编辑"时,本人编辑与管理员编辑两条写通道同关,
// 唯一修正路径=软删(撤回)+ 重新提交——文案直接指路,不让用户对着 403 猜下一步做什么
if (Integer.valueOf(1).equals(form.getFrozen())) {
throw new BizException(ErrorCode.CUSTOM,
"该表单已开启提交后禁编辑,修正请撤回后重新提交:" + form.getCode());
}
// Bug#2:编辑与提交同口径——过截止/表单停用(或已软删)就不再接收修改。
// 否则"先交个占位、截止后随便改"能绕过 deadline,截止语义形同虚设(停用同理:填报入口已失效)。
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收修改:" + form.getCode());
}
if (Integer.valueOf(1).equals(form.getIsDeleted())) {
throw new BizException(ErrorCode.CUSTOM, "表单已删除,不能修改提交:" + form.getCode());
}
if (!FormRuntimeRules.isFillOpen(form)) {
throw new BizException(ErrorCode.CUSTOM, "表单已停用,不能修改提交:" + form.getCode());
}
String valuesJson = values == null || values.isNull() ? "{}" : values.toString(); String valuesJson = values == null || values.isNull() ? "{}" : values.toString();
// 与管理员编辑同一条值契约(按钉住版本、字典放行集);P34:附件三件套必须对得上本校上传记录 // 与管理员编辑同一条值契约(按钉住版本、字典放行集);P34:附件三件套必须对得上本校上传记录
FormValueValidator.validate(valuesJson, pinned.versionRow().getSchemaJson(), FormValueValidator.validate(valuesJson, pinned.versionRow().getSchemaJson(),
dictCode -> dictService.allowedValues(answer.getSchId(), dictCode)); dictCode -> dictService.allowedValues(answer.getSchId(), dictCode));
assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson()); assertAttachmentsOwned(answer.getSchId(), valuesJson, pinned.versionRow().getSchemaJson());
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
deliveries.nextRevision(answer);
answerRepository.save(answer); answerRepository.save(answer);
// 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变 // 索引按钉住版本重建(方案 §5.4),否则改了可筛字段的值而筛选结果不变
indexService.rebuild(answer, pinned.versionRow().getSchemaJson()); indexService.rebuild(answer, pinned.versionRow().getSchemaJson());
opLog(answer.getId(), AnswerOpLog.OP_EDIT, user.getId(), summary == null || summary.trim().isEmpty() opLog(answer.getId(), AnswerOpLog.OP_EDIT, user.getId(), summary == null || summary.trim().isEmpty()
? "本人编辑自己的提交(钉住版本 v" + answer.getVersion() + ")" ? "本人编辑自己的提交(钉住版本 v" + answer.getVersion() + ")"
: "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim()); : "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim());
// 事件:updated(与管理面编辑同一点位;能走到这里说明该表未开冻结,V61 已在上面拦过)
events.answerUpdated(answer);
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_UPDATED);
log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(), log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(),
answer.getVersion()); answer.getVersion());
return toRow(answer, form); return toRow(answer, form);
@@ -377,18 +393,12 @@ public class AnswerReadService {
* {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的), * {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的),
* {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。 * {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。
* *
* <p>与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有 * <p>与数据管理面的区别:那条路径的范围来自 {@link DataScopeService} 的两档解析且入口按 ALL 档
* {@code /admin/data} 模块行;这里<b>不看模块行、不看范围矩阵</b>,只看"这条数据是不是我的", * 把关(契约 v2 C4②);这里<b>不看角色、不看范围</b>,只看"这条数据是不是我的",
* 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。</p> * 所以填表人(学生)不进任何管理面也能用自己的两视图。</p>
*/ */
public List<MyModels.ItemView> listMine(BoaUser user, boolean bySubject) { public List<MyModels.ItemView> listMine(BoaUser user, boolean bySubject) {
if (user == null || user.getId() == null) { Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Long schId = sessionSchId(user);
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
}
List<Answer> answers = bySubject List<Answer> answers = bySubject
? answerRepository.findBySchIdAndSubjectIdOrderByIdDesc(schId, user.getId()) ? answerRepository.findBySchIdAndSubjectIdOrderByIdDesc(schId, user.getId())
: answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId()); : answerRepository.findBySchIdAndSubmitterIdOrderByIdDesc(schId, user.getId());
@@ -414,13 +424,7 @@ public class AnswerReadService {
* 改状态在这一面压根没有入口——V14/V29。 * 改状态在这一面压根没有入口——V14/V29。
*/ */
public ReadModels.DetailView myDetail(BoaUser user, String answerId) { public ReadModels.DetailView myDetail(BoaUser user, String answerId) {
if (user == null || user.getId() == null) { Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Long schId = sessionSchId(user);
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
}
Answer answer = requireLiveAnswer(schId, answerId); Answer answer = requireLiveAnswer(schId, answerId);
boolean mine = user.getId().equals(answer.getSubmitterId()) || user.getId().equals(answer.getSubjectId()); boolean mine = user.getId().equals(answer.getSubmitterId()) || user.getId().equals(answer.getSubjectId());
if (!mine) { if (!mine) {
@@ -455,12 +459,6 @@ public class AnswerReadService {
return item; return item;
} }
/** 会话校(Integer→Long 换算走已有字段,契约 §7 身份行:外部不传) */
private static Long sessionSchId(BoaUser user) {
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
return schId == null ? null : Long.valueOf(schId);
}
/** 表单头表静默取数:答卷归属的表单理论上一定在(P32 连软删的都还在),取不到就让表名列降级为空 */ /** 表单头表静默取数:答卷归属的表单理论上一定在(P32 连软删的都还在),取不到就让表名列降级为空 */
private Form findFormQuietly(Long schId, String formUuid) { private Form findFormQuietly(Long schId, String formUuid) {
try { try {
@@ -0,0 +1,146 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties;
import com.yunhai.formcenter.model.FormSyncModels.InstructionAck;
import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
/**
* 对账指令派发客户端(TASK_3 改动点 3/4)。
*
* <p>出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入,
* 本应用身份取 {@code yh.app-auth},<b>密钥不进代码也不进日志</b>。
* 寻址优先保留服务端显式 targets 覆盖;否则由 UserBase 注册记录把 consumerAppKey
* 映射为标准 appCode,再经 SDK 统一内部网关。消息体/浏览器自报 URL 不参与寻址。</p>
*
* <p>失败语义:本类<b>不抛异常</b>。派发失败对上层只是一个稳定错误码,
* 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。</p>
*/
@Slf4j
@Component
public class AnswerReconcileDispatchClient {
private final YschOpenCall openCall;
private final AnswerReconcileProperties properties;
@org.springframework.beans.factory.annotation.Autowired(required = false)
private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient;
public AnswerReconcileDispatchClient(YschOpenCall openCall, AnswerReconcileProperties properties) {
this.openCall = openCall;
this.properties = properties;
}
/** 出站能力就绪不等于某消费者已登记;具体映射在派发时 fail closed。 */
public boolean enabled() {
return openCall != null && properties != null
&& (appRegistryClient != 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,
"出站签名客户端或平台注册查询不可用,且未配置服务端显式地址",
instruction.getAttempt(), null, false);
}
try {
TargetSpec spec = resolveTarget(instruction.getConsumerAppKey());
InstructionAck ack;
if (spec != null) {
ack = openCall.post(spec, properties.getDispatchPath(),
java.util.Collections.<String, String>emptyMap(), instruction, InstructionAck.class);
} else {
String appCode = registeredAppCode(instruction.getConsumerAppKey());
if (appCode == null) {
return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED,
"消费方未登记标准 appCode 或缺少服务端例外地址",
instruction.getAttempt(), null, false);
}
ack = openCall.post(appCode, "/oapi/form-sync/reconcile",
java.util.Collections.<String, String>emptyMap(), instruction, InstructionAck.class);
}
if (ack == null || ack.getTaskId() == null || !ack.getTaskId().equals(instruction.getTaskId())) {
// 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 2xx)
return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED,
"对端应答的 taskId 与指令不符", instruction.getAttempt(), null, false);
}
if (Boolean.FALSE.equals(ack.getAccepted())) {
return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED,
"对端拒收指令:" + ack.getMessage(), ack.getAttempt(), ack.getState(),
Boolean.TRUE.equals(ack.getDuplicate()));
}
return DispatchOutcome.of(true, null, ack.getMessage(), ack.getAttempt(), ack.getState(),
Boolean.TRUE.equals(ack.getDuplicate()));
} catch (Exception e) {
log.warn("[对账] 派发失败 task={} 第 {} 次:{}", instruction.getTaskId(),
instruction.getAttempt(), e.toString());
return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, brief(e),
instruction.getAttempt(), null, false);
}
}
private String registeredAppCode(String consumerAppKey) {
String key = consumerAppKey.trim();
if (appRegistryClient == null || !key.matches("[0-9]{1,10}")) {
return null;
}
com.fasterxml.jackson.databind.JsonNode registered = appRegistryClient.getAppByKey(key);
if (registered == null || !key.equals(registered.path("appKey").asText())) {
return null;
}
String code = registered.path("appCode").asText("");
return code.matches("[a-z][a-z0-9-]{0,31}") ? code : null;
}
/** 登记的后台基址 → 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 (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null || uri.getRawUserInfo() != null
|| uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException("消费方服务端例外地址必须为 http(s) 基址且不含凭据/query/fragment");
}
String host = uri.getScheme() + "://" + uri.getRawAuthority();
String path = uri.getRawPath() == null ? "" : uri.getRawPath();
return TargetSpec.of(host, path);
}
private String brief(Exception e) {
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
return message.length() > 300 ? message.substring(0, 300) : message;
}
}
@@ -0,0 +1,52 @@
package com.yunhai.formcenter.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.ZoneId;
/**
* 对账建任务的日调度(TASK_3 改动点 1)。
*
* <p>拟配置时点=Asia/Shanghai 每日 02:00,<b>默认停用</b>:开关在
* {@code fc.answer.reconcile.schedule-enabled},没开时这个方法一行任务都不建
* (与 {@code FlowReconcileJob} 的"开关 + 能力闸"同形,但这里没有它的业务筛选——
* 答卷对账扫的是订阅与清单,不是流程状态 10)。</p>
*
* <p>闹钟本身不判授权:{@code createScheduledTasks} 只按 TASK_1 的生效订阅建任务,
* 未授权订阅一行都不产生。调度与派发是两个开关——建了任务不代表会发出去。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AnswerReconcileScheduler {
private final AnswerReconcileService reconcileService;
@Value("${fc.answer.reconcile.schedule-zone:Asia/Shanghai}")
private String zone = "Asia/Shanghai";
/** 营业日=调度触发当天的日期(进入幂等键,同一天重复触发不会叠任务) */
@Scheduled(cron = "${fc.answer.reconcile.schedule-cron:0 0 2 * * ?}",
zone = "${fc.answer.reconcile.schedule-zone:Asia/Shanghai}")
public void createDailyTasks() {
if (!reconcileService.properties().isScheduleEnabled()) {
return; // 默认停用:未显式开闸就不产生任何对账任务
}
LocalDate businessDate = LocalDate.now(zone());
reconcileService.createScheduledTasks(businessDate);
}
private ZoneId zone() {
try {
return ZoneId.of(zone == null || zone.trim().isEmpty() ? "Asia/Shanghai" : zone.trim());
} catch (Exception e) {
log.warn("[对账] 调度时区配置不可用,按 Asia/Shanghai 取营业日:{}", zone);
return ZoneId.of("Asia/Shanghai");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
import com.yunhai.formcenter.enums.FormEnums.DataScope; import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.formcenter.model.ReadModels;
import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Predicate;
@@ -9,30 +10,26 @@ import javax.persistence.criteria.Root;
import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Answer;
/** /**
* 数据范围<b>实现器</b>(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器, * 数据范围<b>实现器</b>(契约 v2 §2.3:两档=两个实现器):一个枚举值一个实现器,
* 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长, * 谓词只回答"这条答卷在不在这一档里"。
* 接口隔离保证读路径不跟着改。
* *
* <p><b>唯一出口约束</b>:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词, * <p><b>唯一出口约束</b>(V55):实现器只产出 SQL 谓词,由 {@link AnswerScopeQueryBuilder}
* 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上 * 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上复用同一次构造。
* 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。</p> * 绕开构造器另写查询=越权后门;实现器里也不允许出现"顺手把 values 取出来判"这类分支。</p>
*
* <p>原"加枚举=加实现器"的开放项(任教班级)随 §19 作废-1 出清:业务语义范围需要时任课数据源
* 落实后单独开批次,不在两档模型里留占位。</p>
*/ */
public interface AnswerScopeImplementor { public interface AnswerScopeImplementor {
/** 本实现器负责的范围值 */ /** 本实现器负责的档 */
DataScope scope(); DataScope scope();
/** /**
* 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK) * 产出该档对单条答卷的行级谓词。
* ——枚举里有、<b>一期不可选</b>(V28),故其实现器返回 false 且不参与解析结果。
*/
boolean selectable();
/**
* 产出该范围对单条答卷的行级谓词。
* *
* @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准) * @param q 查询规格(会话用户 id、会话学校、ALL 档是否租户限定变体都在里面)
* @return 恒真({@code cb.conjunction()})=该范围不额外收窄行 * @return 恒真({@code cb.conjunction()})=该档不额外收窄行
*/ */
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId); Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q);
} }
@@ -29,10 +29,10 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* <b>数据范围查询构造器</b>(方案 §6.4 红线 / 契约 §8 红线的唯一实现处): * <b>数据范围查询构造器</b>(方案 §6.4 红线 / 契约 v2 §2.3 红线的唯一实现处):
* 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关, * 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关,
* <em>全部</em>调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、 * <em>全部</em>调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、
* 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。 * 统计一调返回全校分布,两档范围就白定了),故本类不接受"跳过范围"的开关参数。
* *
* <p>一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)} * <p>一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)}
* → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询) * → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询)
@@ -180,6 +180,8 @@ public class AnswerScopeQueryBuilder {
copy.setCode(q.getCode()); copy.setCode(q.getCode());
copy.setScopes(q.getScopes()); copy.setScopes(q.getScopes());
copy.setUserId(q.getUserId()); copy.setUserId(q.getUserId());
copy.setViewerSchId(q.getViewerSchId());
copy.setPlatformScopedAll(q.isPlatformScopedAll());
copy.setStatus(q.getStatus()); copy.setStatus(q.getStatus());
copy.setFilters(q.getFilters()); copy.setFilters(q.getFilters());
copy.setCursorId(q.getCursorId()); copy.setCursorId(q.getCursorId());
@@ -219,14 +221,15 @@ public class AnswerScopeQueryBuilder {
return ps; return ps;
} }
/** 范围段:多来源的并集(OR);命中"全部"即不加行级条件 */ /** 范围段:两档的并集(OR);ALL 档自身可能是租户限定变体,收窄谓词在实现器里(V55 唯一出口) */
private Predicate scopePredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) { private Predicate scopePredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
Set<DataScope> scopes = q.getScopes(); Set<DataScope> scopes = q.getScopes();
if (scopes == null || scopes.isEmpty()) { if (scopes == null || scopes.isEmpty()) {
// 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须是仅本人提交 // 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须拒绝
throw new BizException(ErrorCode.FORBIDDEN, "未解析出数据范围,拒绝按无范围读取"); throw new BizException(ErrorCode.FORBIDDEN, "未解析出数据范围,拒绝按无范围读取");
} }
if (scopes.contains(DataScope.ALL)) { if (scopes.contains(DataScope.ALL) && !q.isPlatformScopedAll()) {
// 全量 ALL 已经覆盖 SELF:留着 SELF 那半只会让读路径多写几个 OR
return cb.conjunction(); return cb.conjunction();
} }
if (q.getUserId() == null) { if (q.getUserId() == null) {
@@ -234,7 +237,7 @@ public class AnswerScopeQueryBuilder {
} }
List<Predicate> ors = new ArrayList<>(); List<Predicate> ors = new ArrayList<>();
for (DataScope scope : scopes) { for (DataScope scope : scopes) {
ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q.getUserId())); ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q));
} }
return cb.or(ors.toArray(new Predicate[0])); return cb.or(ors.toArray(new Predicate[0]));
} }
@@ -8,8 +8,11 @@ import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.FillQuota; import com.yunhai.formcenter.entity.FillQuota;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile; import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.AnonModels;
import com.yunhai.formcenter.repository.AnswerRepository; import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FillQuotaRepository; import com.yunhai.formcenter.repository.FillQuotaRepository;
import com.yunhai.formcenter.repository.FormFileRepository; import com.yunhai.formcenter.repository.FormFileRepository;
@@ -25,19 +28,28 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
/** /**
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关: * 提交链路(契约 v2 §3.1,采集管线唯一=D16/§3.4:手工填报、代录、批量导入三者同走这条流水线)。
* 一次提交依次过关:
* *
* <pre> * <pre>
* 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本) * 钉版解析 → <b>audience 门</b> → 截止 → 幂等命中 → subject 授权(=ALL 档)+存在性同校(P33)
* → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid) * → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
* → 写 answer_index(只写 searchable) * → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
* </pre> * </pre>
* *
* <p><b>为什么顺序是这样</b>:</p> * <p><b>为什么顺序是这样</b>:</p>
* <ul> * <ul>
* <li><b>先鉴权后业务</b>:audience 门放在钉版之后(要 {@code form.audience} 才能判人群)、
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。它取代 P95 的第 0 关
* 三层入口层(《契约v2》§19 作废-2)——资格从此只有一个问题:"这个人的身份类型在不在面向人群里";
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403(沿 W2 明示可接受的取舍);
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
* {@code belongsToCaller} 即返回原答卷);</li>
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li> * <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
* <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插 * <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插
* (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li> * (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li>
@@ -46,6 +58,16 @@ import java.util.UUID;
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li> * "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li>
* </ul> * </ul>
* *
* <p><b>三条线的分工(v2 后只剩两条半)</b>:audience 门(本类)判"能不能填这张表";
* {@link #requireSubject} 判"subject 能不能指向别人"=该表单 ALL 档({@link DataScopeService});
* 同一次解析的结果同时决定"写进去之后谁能看见这行"。<b>代录与导入也走这里</b>(§3.4/V60),
* 所以"代录的权限"与"手工填的权限"不可能有两套口径。</p>
*
* <p><b>两条通道、一条管线</b>(§3.2/V60):{@link #submit} 带 CAS 会话,{@link #submitAnon} 带
* {@link AnonModels.Context}(IP/UA/防重窗口剩余秒数)。差别全部收在 {@link SubmitIdentity} 里——
* 租户域、提交人、人群门方向、限次适用性、幂等归属五处各按分支取值,值校验/附件/钉版/报名号/索引
* 一段代码共用。匿名面没有第二份写库实现,这是 V56/V57/V60 三条红线的同一个落点。</p>
*
* <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 * <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id * 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p> * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
@@ -57,17 +79,26 @@ public class AnswerService {
/** 答案号日期段格式:{@code {code}-{yyyyMMdd}-{当日序号}}(契约 §5) */ /** 答案号日期段格式:{@code {code}-{yyyyMMdd}-{当日序号}}(契约 §5) */
private static final DateTimeFormatter ANSWER_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd"); private static final DateTimeFormatter ANSWER_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 报名号并发撞号后的重试上限(窗口极窄:同表单同日同刻提交且序号算到同一个数) */
private static final int ANSWER_NO_RETRY = 3;
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE; private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
/** 匿名提交人哨兵值(契约 v2 C8:库内不自增、不指向任何 UserBase 用户) */
private static final Integer ANONYMOUS_SUBMITTER_ID = 0;
private final FormDefService defService; private final FormDefService defService;
private final FormDictService dictService; private final FormDictService dictService;
private final FormAdminGuard adminGuard; private final DataScopeService dataScope;
private final SubjectDirectory subjectDirectory; private final SubjectDirectory subjectDirectory;
private final AnswerRepository answerRepository; private final AnswerRepository answerRepository;
private final FillQuotaRepository quotaRepository; private final FillQuotaRepository quotaRepository;
private final FormFileRepository fileRepository; private final FormFileRepository fileRepository;
private final AnswerIndexService indexService; private final AnswerIndexService indexService;
private final TransactionTemplate transactionTemplate; 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;
/** /**
* 提交答卷。 * 提交答卷。
@@ -76,12 +107,51 @@ public class AnswerService {
*/ */
public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) { public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey());
return executeSubmit(SubmitIdentity.ofSession(user, req.getContextToken()), req, idempotencyKey,
ANSWER_NO_RETRY);
}
/**
* 匿名提交(契约 v2 §3.2,T15):<b>与 {@link #submit} 同一条管线</b>(V60),差别只在身份——
* 租户域取表单归属校而不是会话校(C8/V57)、{@code submitter_id=0}、{@code subject_id=NULL}、
* 人群门在解析阶段就以"audience 必须含 ANONYMOUS"的形式判过、限次不适用(作废-11),
* 另加 IP/UA 两列留痕。请求体里没有 subjectId/appCode 之外的任何身份字段。
*
* @param ctx 服务端从 HTTP 请求取的留痕与防重判定结果(请求体无法影响它)
*/
public AnswerModels.SubmitView submitAnon(AnonModels.SubmitRequest req, AnonModels.Context ctx) {
String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey());
AnswerModels.SubmitRequest internal = new AnswerModels.SubmitRequest();
internal.setFormCode(req.getFormCode());
internal.setAppCode(req.getAppCode());
internal.setIdempotencyKey(idempotencyKey);
internal.setValues(req.getValues());
// subjectId 不填:匿名请求的字段闭集里没有它,两个 DTO 分开写就是为了让它无从出现(§3.3)
return executeSubmit(SubmitIdentity.ofAnon(ctx), internal, idempotencyKey, ANSWER_NO_RETRY);
}
/**
* 提交事务的一次尝试。报名号是"当日已有号数+1"(count 与 insert 之间有并发窗口),
* 撞 {@code uk_answer_no} 时重算序号重建事务——重新提交时对手那条已提交,count 已把它算进去。
*/
private AnswerModels.SubmitView executeSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req,
String idempotencyKey, int remaining) {
try { try {
return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey)); return transactionTemplate.execute(tx -> doSubmit(identity, req, idempotencyKey));
} catch (DataIntegrityViolationException e) { } catch (DataIntegrityViolationException e) {
// 只剩两种可能:幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能) if (isAnswerNoCollision(e)) {
if (remaining > 1) {
log.warn("[提交] 报名号并发撞号,重算序号重试(剩 {} 次)", remaining - 1);
return executeSubmit(identity, req, idempotencyKey, remaining - 1);
}
throw new BizException(ErrorCode.CUSTOM, "报名号生成冲突,请稍后重试(同一表单并发提交过多)");
}
// 幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能)
Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (replay != null && belongsToCaller(replay, user)) { if (replay != null && identity.ownsReplay(replay)) {
if (identity.hosted()) {
requireHostedReplay(identity, req, replay);
}
log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid());
return toView(replay, true); return toView(replay, true);
} }
@@ -89,43 +159,122 @@ public class AnswerService {
} }
} }
private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) { /**
Long schId = requireSessionSchId(user); * 撞的是不是报名号唯一键:MySQL 报 {@code Duplicate entry ... for key 'uk_answer_no'},
Integer submitterId = user.getId(); * H2 报 {@code Unique index ... ON ... (ANSWER_NO ...)}——两处消息都含列名 {@code answer_no},
* 而 answer 上另两个唯一键(uuid / idempotency_key)都不含它,按小写子串判定即可跨库。
*/
private static boolean isAnswerNoCollision(DataIntegrityViolationException e) {
Throwable cause = e;
while (cause != null) {
String message = cause.getMessage();
if (message != null && message.toLowerCase().contains("answer_no")) {
return true;
}
cause = cause.getCause();
}
return false;
}
private AnswerModels.SubmitView doSubmit(SubmitIdentity identity, AnswerModels.SubmitRequest req,
String idempotencyKey) {
BoaUser user = identity.user;
// 租户域(V57):登录面=会话校,无学校归属直接拒;匿名面=表单归属校,无会话可取,
// 因此必须等钉版解析出表单以后才有值(C8 把"恒=会话校"改成了两条分支)
Long sessionSchId = identity.anonymous()
? null : FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属,不能提交答卷");
String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
? DEFAULT_APP_CODE : req.getAppCode().trim(); ? DEFAULT_APP_CODE : req.getAppCode().trim();
// 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地) // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地)。
FormDefService.Published published = defService.resolvePublished(schId, 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(); Form form = published.form();
String schemaJson = published.versionRow().getSchemaJson(); String schemaJson = published.versionRow().getSchemaJson();
Long schId = identity.anonymous() ? form.getSchId() : sessionSchId;
Integer submitterId = identity.submitterId();
// 2) 截止(datetime 粒度,过期接口拒绝,契约 §5) // 2) audience 门(契约 v2 §3.1):钉版之后才有 audience 可判,截止之前先鉴权。
// 只对<b>自填</b>生效(P116 裁决:§3.4 与 §5.1/V73 冲突点按"代录看 ALL 档、不看操作者人群"收敛)。
// 匿名通道不进这里:解析阶段已把"audience 含 ANONYMOUS"作为寻址条件(§3.2),
// 而会话身份集合里永远没有 ANONYMOUS(C1)——两条通道对同一个集合用两个方向判,不会互相放行
if (!identity.anonymous()) {
requireAudienceGate(user, form, req.getSubjectId(), submitterId);
}
// 3) 截止(datetime 粒度,过期接口拒绝,契约 §5)
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode()); throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode());
} }
// 3) 幂等命中→200+原 answer_id(在限次之前,见类注释) // 4) 幂等命中→200+原 answer_id(在限次之前,见类注释)
Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
if (existing != null) { if (existing != null) {
if (!belongsToCaller(existing, user)) { if (!identity.ownsReplay(existing)) {
// 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer throw replayConflict(identity);
throw new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用"); }
if (identity.hosted()) {
requireHostedReplay(identity, req, existing);
} }
return toView(existing, true); return toView(existing, true);
} }
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33) // 4.5) 匿名防重窗口(C7):命中=CUSTOM(5),文案给剩余分钟。
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId); // 位置在幂等命中<b>之后</b>:双击重放落在同一张答卷上,按契约必须是 200+原 answerId,
// 不能被节流阀报成"提交太频繁";又必须在任何写库动作之前,否则窗口内会留下名额/号段消耗
if (identity.anonymous() && identity.anon().getThrottleRemainingSeconds() > 0) {
throw new BizException(ErrorCode.CUSTOM, "该表单 " + AnonReplayGuard.windowMinutes(form.getAnonCookieMinutes())
+ " 分钟内只能提交一次,请约 " + AnonReplayGuard.remainingMinutes(
identity.anon().getThrottleRemainingSeconds()) + " 分钟后再提交:" + form.getCode());
}
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 // 5) subject 指定权(契约 v2 §3.1:=该表单 ALL 档持有者,原 canManage 代填路径收敛到此)
// + 存在性/同校(P33)
Integer subjectId = requireSubject(user, form, req.getSubjectId(), submitterId, schId);
// Bug#3:自填(未传 subjectId 或传了本人)落库时把 subject 归一为本人。
// 否则 subject_id=NULL 的自填行永远进不了"关于我的"(/my/about 与 ABOUT_ME 谓词都按 subject 查),
// V18"关于我(含代填)"实际退化成"仅代填"。限次键本就是 coalesce(subject, submitter),归一不改它。
// 匿名分支反过来必须留 NULL(C8):submitter_id=0 不是任何人,归一过去会造出一个指向 0 号用户的"关于我的"行
Integer storedSubjectId = identity.anonymous() ? null : (subjectId != null ? subjectId : submitterId);
// 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
// 放行集与附件归属都按 schId 判,匿名面的 schId=表单归属校,与 §3.2 的"同登录面同构校验"一致
String valuesJson = valuesToJson(req.getValues()); String valuesJson = valuesToJson(req.getValues());
FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode));
assertAttachmentsOwned(schId, valuesJson, schemaJson); assertAttachmentsOwned(schId, valuesJson, schemaJson);
// 6) 限次占位(uk 是并发兜底) // 7) 限次占位(uk 是并发兜底)。匿名不适用(作废-11:无身份无 limit_key,
String quotaKey = occupyQuota(form, subjectId, submitterId); // 配置通道已在 C1 处禁止"含 ANONYMOUS 且 fill_limit=1",这里不再占位)
String quotaKey = identity.anonymous() ? null : occupyQuota(form, storedSubjectId, submitterId);
// 7) 落答卷 // 8) 落答卷
Answer answer = new Answer(); Answer answer = new Answer();
answer.setUuid(newUuid()); answer.setUuid(newUuid());
// P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行) // P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行)
@@ -135,33 +284,95 @@ public class AnswerService {
answer.setCode(form.getCode()); answer.setCode(form.getCode());
answer.setVersion(published.versionRow().getVersion()); answer.setVersion(published.versionRow().getVersion());
answer.setSubmitterId(submitterId); answer.setSubmitterId(submitterId);
answer.setSubjectId(subjectId); // T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证
// (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)。
// 匿名分支同值=表单归属校,于是"校级匿名表单的匿名答卷落校管理员 ALL 档、
// 平台级匿名表单的匿名答卷只有创建人(超管)看得见"两条推论自动成立(§2.3/C8)
answer.setSubmitterSchId(schId);
answer.setSubjectId(storedSubjectId);
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
answer.setAnswerNo(nextAnswerNo(form)); answer.setAnswerNo(nextAnswerNo(form));
answer.setIdempotencyKey(idempotencyKey); answer.setIdempotencyKey(idempotencyKey);
// 首版快照号=1(之后每次对外可见的变化由 AnswerDeliveryService.nextRevision 行级 CAS 推进)
answer.setAnswerRevision(1L);
if (identity.anonymous()) {
// 留痕进独立列,不进 values_json(C8/C12:值文档是业务数据,IP/UA 是审计数据)
answer.setAnonIp(identity.anon().getAnonIp());
answer.setAnonUa(identity.anon().getAnonUa());
}
// saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放); // saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放);
// 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态
answer = answerRepository.saveAndFlush(answer); answer = answerRepository.saveAndFlush(answer);
// 8) 索引:只写 searchable 字段(V3/V9) // 9) 索引:只写 searchable 字段(V3/V9)
indexService.write(answer, schemaJson); indexService.write(answer, schemaJson);
log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", // 9.5) 可信关联:意图 → 答卷。必须与答卷<b>同一事务</b>(A.3"原子关联"),
answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(), // 并且排在投递入队<b>之前</b>——outbox 载荷里的 intent 块就是从这里读的。
submitterId, subjectId, quotaKey); if (hostedCtx != null) {
contexts.link(hostedCtx, answer);
}
// 9.6) v2 快照投递入队(<b>同事务</b>,B.1/B.4):无订阅的简单表单不建任何投递行,
// 答卷照常提交(B.2 末句);撞同 revision 的唯一键则整事务回滚,不产生两份 hash
deliveries.enqueueAndWake(answer, AnswerDeliveryModels.EVENT_CREATED);
// 10) 事件:created 只在"真提交"这条路上发(幂等命中在第 4 关就 return 了,不发事件)。
// 发布器内部是 afterCommit + 失败仅 warn + 未配 broker 即 no-op,主流程零风险(§6.2 裁决 2/3)
events.answerCreated(answer);
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); return toView(answer, false);
} }
// ==================== 各环节 ==================== // ==================== 各环节 ====================
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId, Long schId) { /**
* audience 门(契约 v2 §3.1):<b>自填</b>资格=会话身份类型 ∈ 表单面向人群(∩ 状态门在后面几关)。
*
* <p><b>代录不吃这道门</b>(问题单 <b>P116</b>,用户 2026-10-20 裁决):§3.4 写"audience 门按代录
* 操作者会话判定",而 §5.1/V73 又要求管理员视图能"添加(代录)"、批量导入走同一管线——
* 校管理员(roleId 2/7)本身不在学生或教师人群里,按字面判会让代录对管理员整体失效。
* 两条承诺只能留一条,留的是<b>功能可用</b>那条:{@code subject} 指向他人时,操作者的身份
* 不代表填报人,资格由"是不是该表单 ALL 档持有者"决定(在 {@link #requireSubject} 判),
* 人群门留给自填。</p>
*
* <p>拒绝文案必须<b>可诊断</b>(承接原第 0 关的文案纪律):带上表单 code、配置的 audience
* 集合、当前会话的身份要素——"人群配错了"与"这个人确实不在人群里"要能在日志里一眼分开。</p>
*/
private void requireAudienceGate(BoaUser user, Form form, Integer subjectId, Integer submitterId) {
if (subjectId != null && !subjectId.equals(submitterId)) {
return;
}
Set<FormEnums.Audience> identities = FormAccessPolicy.identityTypes(user);
Set<FormEnums.Audience> audience = FormEnums.Audience.parse(form.getAudience());
if (!Collections.disjoint(identities, audience)) {
return;
}
throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向人群="
+ audience + "(" + FormEnums.Audience.labelOf(form.getAudience()) + "),当前身份 "
+ sessionIdentity(user) + " 不在人群内(自填资格=身份类型 ∈ audience;代录请传 subjectId,"
+ "由该表单 ALL 档把关)");
}
/**
* subject 指定权(契约 v2 §3.1/G1):只有该表单 <b>ALL 档</b>持有者(创建人 ∪ form_admin ∪
* 校管理员 ∪ 超管)能把答卷挂到别人名下;其余会话 subject 只能=本人。
*
* <p>原 {@code FormAdminGuard#canManage}(授权模块行判"有没有代填权")随模块行退役收敛到这里
* ——数据职权与"能替谁填"从此是同一个事实源(V55 的写侧对应面)。P33 的存在性+同校复核不变。</p>
*/
private Integer requireSubject(BoaUser user, Form form, Integer subjectId,
Integer submitterId, Long schId) {
if (subjectId == null || subjectId.equals(submitterId)) { if (subjectId == null || subjectId.equals(submitterId)) {
return subjectId; return subjectId;
} }
if (!adminGuard.canManage(user)) { if (!dataScope.resolve(user, form).all()) {
throw new BizException(ErrorCode.FORBIDDEN, throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")");
} }
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。 // P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。 // 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
@@ -172,6 +383,19 @@ public class AnswerService {
return subjectId; return subjectId;
} }
private static String forbiddenProxy(Integer submitterId, Integer subjectId) {
return "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")";
}
/** 拒绝文案里的身份要素(会话 roleIds + 当前身份,便于运维一眼看出是人群配错还是身份不对) */
private static String sessionIdentity(BoaUser user) {
StringBuilder sb = new StringBuilder("id=").append(user.getId()).append(" roleIds=").append(user.getRoleIds());
if (user.getCurrentRole() != null && user.getCurrentRole().getRoleId() != null) {
sb.append("(当前=").append(user.getCurrentRole().getRoleId()).append(")");
}
return sb.toString();
}
/** 返回占住的 limit_key(null=该表单不限次,未占位) */ /** 返回占住的 limit_key(null=该表单不限次,未占位) */
private String occupyQuota(Form form, Integer subjectId, Integer submitterId) { private String occupyQuota(Form form, Integer subjectId, Integer submitterId) {
if (!FormEnums.FillLimit.once(form.getFillLimit())) { if (!FormEnums.FillLimit.once(form.getFillLimit())) {
@@ -253,27 +477,42 @@ public class AnswerService {
return clean; return clean;
} }
private Long requireSessionSchId(BoaUser user) { /** 幂等键被别的行占着时的回答(两条通道都不泄露对方那条答卷的任何信息,契约 §6 错误码族) */
if (user == null || user.getId() == null) { private static BizException replayConflict(SubmitIdentity identity) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); return identity.anonymous()
} // 匿名请求撞到的若是登录答卷,键空间相同纯属偶然;给冲突而不是"已被他人占用"
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { ? new BizException(ErrorCode.PARAM_ERROR, "提交冲突,请稍后重试(幂等键可能已被占用)")
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能提交答卷"); // 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer
} : new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用");
return Long.valueOf(user.getCurrentRole().getSchId());
}
private boolean belongsToCaller(Answer answer, BoaUser user) {
// 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行)
return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId())
&& user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null
&& answer.getSchId() != null && answer.getSchId().longValue() == user.getCurrentRole().getSchId();
} }
private static String newUuid() { private static String newUuid() {
return UUID.randomUUID().toString().replace("-", "").toLowerCase(); return UUID.randomUUID().toString().replace("-", "").toLowerCase();
} }
private void requireHostedReplay(SubmitIdentity identity, AnswerModels.SubmitRequest request, Answer answer) {
Long schoolId = FormAccessPolicy.requireSessionSchId(identity.user, "当前身份无学校归属");
SubmissionContext context = contexts.redeem(identity.contextToken(), identity.user, schoolId);
Answer linked = contexts.linkedAnswer(context);
String app = request.getAppCode() == null || request.getAppCode().trim().isEmpty()
? DEFAULT_APP_CODE : request.getAppCode().trim();
contexts.assertCoordinates(context, app, request.getFormCode());
if (linked == null || !answer.getUuid().equals(linked.getUuid())
|| !context.getFormUuid().equals(answer.getFormUuid())
|| !context.getFormVersion().equals(answer.getVersion())
|| !schoolId.equals(answer.getSchId())
|| (request.getSubjectId() != null && !request.getSubjectId().equals(identity.submitterId()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "幂等键已被其他提交占用,不能跨意图复用");
}
com.fasterxml.jackson.databind.JsonNode stored = com.yunhai.formcenter.common.FormSchemaValidator
.parse(answer.getValuesJson(), "答卷值文档损坏");
com.fasterxml.jackson.databind.JsonNode requested = request.getValues() == null
? com.yunhai.formcenter.common.FormSchemaValidator.mapper().createObjectNode() : request.getValues();
if (!AnswerCanonicalizer.canonical(stored).equals(AnswerCanonicalizer.canonical(requested))) {
throw new BizException(ErrorCode.PARAM_ERROR, "同一提交上下文与幂等键不能携带不同内容重放");
}
}
private AnswerModels.SubmitView toView(Answer answer, boolean replayed) { private AnswerModels.SubmitView toView(Answer answer, boolean replayed) {
AnswerModels.SubmitView view = new AnswerModels.SubmitView(); AnswerModels.SubmitView view = new AnswerModels.SubmitView();
view.setAnswerId(answer.getUuid()); view.setAnswerId(answer.getUuid());
@@ -281,4 +520,73 @@ public class AnswerService {
view.setReplayed(replayed); view.setReplayed(replayed);
return view; return view;
} }
/**
* 提交身份(契约 v2 §3.1/§3.2 两条通道的唯一差别)。
*
* <p>管线里每一个"谁能填/替谁填/落进哪个租户/这条幂等记录算不算你的"判定都从这里取事实,
* 于是匿名通道不需要第二份提交实现(V60 采集管线唯一):值校验/附件/钉版/报名号/索引一段代码
* 共用。新通道(如二期 oapi 代提交)也只需要再加一个工厂方法。</p>
*
* <p><b>通道是入口显式声明的,不从 {@code user==null} 反推</b>(V58 身份推断单点的同一件事):
* 反推会让"登录面漏传会话"这种调用错误静默变成"走匿名分支写一条匿名答卷"——既绕过人群门,
* 又把数据落到表单归属校。现在 {@code submit(null, …)} 照旧在 {@code requireSessionSchId}
* 处报"未登录"({@code AnswerSubmitIT.sessionRequired} 钉住)。</p>
*/
private static final class SubmitIdentity {
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, 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, String contextToken) {
return new SubmitIdentity(user, null, false, contextToken);
}
static SubmitIdentity ofAnon(AnonModels.Context anon) {
// 匿名分支永远不带令牌:hosted 首批只允许登录本人,匿名完成消息不能用于本人办件(A.3)
return new SubmitIdentity(null, anon, true, null);
}
boolean anonymous() {
return anonymous;
}
AnonModels.Context anon() {
return anon;
}
String contextToken() {
return contextToken;
}
boolean hosted() {
return contextToken != null;
}
/** 提交人:会话用户 id,或匿名哨兵 0(C8,库内不自增、不指向任何 UserBase 用户) */
Integer submitterId() {
return anonymous() ? ANONYMOUS_SUBMITTER_ID : user.getId();
}
/** 幂等命中的归属判定(C7:匿名"无身份可比对",只额外要求命中行本身是匿名行) */
boolean ownsReplay(Answer answer) {
if (anonymous()) {
return ANONYMOUS_SUBMITTER_ID.equals(answer.getSubmitterId());
}
// 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行)
Long sessionSch = FormAccessPolicy.sessionSchId(user);
return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId())
&& sessionSch != null && answer.getSchId() != null && answer.getSchId().equals(sessionSch);
}
}
} }
@@ -0,0 +1,158 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.SubmissionContext;
import com.yunhai.formcenter.model.AnswerDeliveryModels.Snapshot;
import com.yunhai.formcenter.model.AnswerDeliveryModels.SnapshotView;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.SubmissionContextRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 对外答卷快照的唯一装配点(answer-delivery-v2 §2/§4,TASK_2 B.1/B.2、TASK_3 扩展点)。
*
* <p><b>成员闭集就是契约</b>:{@code schId/appCode/code/submitterId/subjectId/status/values/
* answerNo/createTime/updateTime},再加一个<b>仅对该 consumer 可见</b>的 {@code intent} 关联块。
* 匿名 IP/UA 是审计数据、库内 {@code id} 是内部坐标,两者都<b>不进</b>快照(v2 契约 §2 明文);
* 附件保持 {@code {id,fileName,key}} 元数据,永远不塞文件字节(P34 的口径)。</p>
*
* <p><b>消费端不回查值文档</b>(B.6):快照里给的就是全文,投出去即完整。因此本类的输出与
* {@link AnswerCanonicalizer} 的哈希口径必须是同一份字节——两处都从这里取,不给分叉机会。</p>
*
* <p>{@code intent} 块剔除出哈希(fc.canon.v1 规则⑥):同一 revision 对 A 应用带意图、
* 对 B 应用不带,若不剔除就会出现"同版两份 hash",直接违 B.1 的不变式。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerSnapshotService {
/**
* 固定时间形态:{@code yyyy-MM-dd'T'HH:mm:ss}(秒恒出现)。
* 不用 {@code ISO_LOCAL_DATE_TIME}——它会在秒为 0 时省掉秒段,
* 于是 {@code 10:00:00} 与 {@code 10:00} 两种字节串都合法,跨端复算的哈希就再也不能逐字比。
*/
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final ObjectMapper objectMapper;
private final AnswerRepository answerRepository;
private final SubmissionContextRepository contextRepository;
private final FormConsumerAccessService accessService;
/** 核心快照(不含 intent 关联块)——哈希按它算 */
public Snapshot core(Answer answer) {
Snapshot snapshot = new Snapshot();
snapshot.setSchId(answer.getSchId());
snapshot.setAppCode(answer.getAppCode());
snapshot.setCode(answer.getCode());
snapshot.setSubmitterId(answer.getSubmitterId());
snapshot.setSubjectId(answer.getSubjectId());
snapshot.setStatus(answer.getStatus());
snapshot.setValues(parseValues(answer));
snapshot.setAnswerNo(answer.getAnswerNo());
snapshot.setCreateTime(format(answer.getCreateTime()));
snapshot.setUpdateTime(format(answer.getUpdateTime()));
return snapshot;
}
/** 带意图关联的快照(载荷与哈希分离:intent 只进载荷) */
public Snapshot forConsumer(Answer answer, String consumerAppKey, Long consumerSchoolId) {
Snapshot snapshot = core(answer);
JsonNode intent = intentBlock(answer.getUuid(), consumerAppKey, consumerSchoolId);
if (intent != null) {
snapshot.setIntent(intent);
}
return snapshot;
}
/** 核心快照的规范串与哈希(入队时算一次,落库不可变) */
public String contentHash(Answer answer) {
return AnswerCanonicalizer.hashSnapshot(toJson(core(answer)));
}
public String canonicalCore(Answer answer) {
return AnswerCanonicalizer.canonical(toJson(core(answer)));
}
/**
* TASK_3 扩展点:授权下的当前快照读取(对账/补投用,签名消费方直取)。
*
* <p>判据只有 TASK_1 那把 {@code requireRead}(读快照=读答卷数据),租户域按
* {@code consumerSchoolId} 限定行集——平台级表单也给不出别校的答卷。
* 返回的 revision/hash 与投递行同一条规范化口径,对账两侧可比对。</p>
*/
public SnapshotView currentAuthorized(ConsumerPrincipal caller, Long consumerSchoolId, String answerUuid) {
Answer answer = answerRepository.findByUuidAndSchId(requireText(answerUuid, "answerUuid 不能为空"),
consumerSchoolId).orElse(null);
if (answer == null) {
// 不存在与跨校同一口径(uuid 不可枚举,契约 §1)
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid);
}
accessService.requireRead(caller, consumerSchoolId, answer.getFormUuid());
SnapshotView view = new SnapshotView();
view.setAnswerUuid(answer.getUuid());
view.setAnswerRevision(answer.getAnswerRevision());
view.setIsDeleted(Integer.valueOf(1).equals(answer.getIsDeleted()));
view.setContentHash(contentHash(answer));
view.setSnapshot(forConsumer(answer, caller == null ? null : caller.getAppKey(), consumerSchoolId));
return view;
}
/** 快照 → JSON(ObjectMapper 只做结构转换,序列化字节由 canonicalizer 定) */
public JsonNode toJson(Snapshot snapshot) {
return objectMapper.valueToTree(snapshot);
}
public JsonNode parseSnapshotJson(String json) {
return FormSchemaValidator.parse(json, "投递载荷损坏");
}
private JsonNode intentBlock(String answerUuid, String consumerAppKey, Long consumerSchoolId) {
if (consumerAppKey == null) {
return null;
}
SubmissionContext ctx = contextRepository.findByAnswerUuid(answerUuid).orElse(null);
if (ctx == null || !consumerAppKey.equals(ctx.getConsumerAppKey())) {
return null;
}
if (consumerSchoolId != null && !consumerSchoolId.equals(ctx.getConsumerSchoolId())) {
return null;
}
ObjectNode node = objectMapper.createObjectNode();
node.put("intentId", ctx.getIntentId());
node.put("consumerAppKey", ctx.getConsumerAppKey());
node.put("consumerSchoolId", ctx.getConsumerSchoolId());
node.put("submitterId", ctx.getSubmitterId());
node.put("formVersion", ctx.getFormVersion());
node.put("linkedRevision", ctx.getLinkedRevision());
return node;
}
private JsonNode parseValues(Answer answer) {
return FormSchemaValidator.parse(answer.getValuesJson() == null ? "{}" : answer.getValuesJson(),
"答卷值文档损坏:" + answer.getUuid());
}
private static String format(LocalDateTime time) {
return time == null ? null : TS.format(time);
}
private static String requireText(String raw, String message) {
if (raw == null || raw.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, message);
}
return raw.trim();
}
}
@@ -0,0 +1,184 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.AppRegistry;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.AppModels;
import com.yunhai.formcenter.repository.AppRegistryRepository;
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.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
/**
* 快应用注册表读写(契约 v2 §5.2/C17,T16,验收 V59/V65/V69)。
*
* <p><b>这张表不答的问题比它答的更重要</b>:它不管"谁能用哪个应用"(零角色列,V59 红线)——
* 应用里每张表单的可见范围都由 {@link DataScopeService} 按会话现算 ALL/SELF 档。注册只是
* "给一个人读 slug 上户口",好让入口链接、oapi 游标域段与 MQ routing key 有个全局唯一的锚点。</p>
*
* <p>四条写侧规则:</p>
* <ol>
* <li><b>code 只进不改</b>:格式按 C10(小写 slug,2~64 位,不许连字符开头/结尾),保留字
* {@code form-center} 拒绝;{@code uk_app_code} 全库唯一<b>且不含 is_deleted</b>,
* 所以删过的 code 也不复用——撞键前先在库里点名,给可诊断文案而不是库内 500(P125);</li>
* <li><b>域=会话域</b>:校管理员只能建/改/删本校行,平台级行({@code sch_id=0})只有超管显式
* {@code schId=0} 时才动得了;他校行按"app 不存在"回答(与数据面他校同一条防探测口径);</li>
* <li><b>在用禁删</b>:名下存在未软删表单(不分草稿/发布/停用)即 CUSTOM(5),msg 指点先迁先删;
* 判据按 code 全库统计而不是按校,因为 code 本身是全库唯一的(窄的一边,同 P117 的取向);</li>
* <li><b>删除=软删</b>:实体 save 改标记(全仓软删都保持实体写,bulk UPDATE 会绕过审计监听器)。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AppRegistryService {
/** C10:2~64 位小写 slug(首尾必须字母/数字,中间可带连字符)——比 form.code 的字符集严,因为它要进链接与 routing key */
private static final Pattern APP_CODE_PATTERN = Pattern.compile("^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$");
private static final int NAME_MAX = 128;
private final AppRegistryRepository appRepository;
private final FormRepository formRepository;
/** 部署上下文路径(生产 {@code CONTEXT_PATH=/form-center} 注入,本地为空)——入口链接的段前缀 */
@Value("${server.servlet.context-path:}")
private String contextPath;
// ==================== 读 ====================
/** 清单:本校 ∪ 平台级(C17"按会话域"),软删行不进 */
public List<AppModels.AppView> list(Long domain) {
List<AppModels.AppView> views = new ArrayList<>();
for (AppRegistry row : appRepository.findLiveInDomain(Arrays.asList(domain, Form.PLATFORM_SCH_ID))) {
views.add(toView(row));
}
return views;
}
// ==================== 写 ====================
/** 建:校管理员落会话校,超管可用 {@code schId} 指定目标域(含 0=平台级);无审核(D14) */
@Transactional
public AppModels.AppView create(Long domain, AppModels.CreateRequest req) {
String appCode = requireCode(req == null ? null : req.getAppCode());
String name = requireName(req == null ? null : req.getName());
AppRegistry existing = appRepository.findByAppCode(appCode).orElse(null);
if (existing != null) {
throw new BizException(ErrorCode.CUSTOM, Integer.valueOf(0).equals(existing.getIsDeleted())
? "app_code 已存在:" + appCode
: "app_code 曾被注册且已删除,编码不复用(uk_app_code 全库唯一,请换 code):" + appCode);
}
AppRegistry row = new AppRegistry();
row.setAppCode(appCode);
row.setName(name);
row.setSchId(domain);
try {
row = appRepository.saveAndFlush(row);
} catch (DataIntegrityViolationException e) {
// 并发注册同一 code:uk 是正主,这里只补一句人话
throw new BizException(ErrorCode.CUSTOM, "app_code 已存在(并发注册):" + appCode);
}
log.info("[app 注册] 新建 {}(sch_id={},name={})", appCode, domain, name);
return toView(row);
}
/** 改名(C17 只开这一件写:code 是对外坐标,不随改) */
@Transactional
public AppModels.AppView rename(Long domain, String rawAppCode, AppModels.UpdateRequest req) {
AppRegistry row = requireInDomain(domain, rawAppCode);
row.setName(requireName(req == null ? null : req.getName()));
return toView(appRepository.save(row));
}
/** 软删(在用禁删) */
@Transactional
public AppModels.AppView delete(Long domain, String rawAppCode) {
AppRegistry row = requireInDomain(domain, rawAppCode);
long inUse = formRepository.countLiveByAppCode(row.getAppCode());
if (inUse > 0) {
throw new BizException(ErrorCode.CUSTOM, "app_code「" + row.getAppCode() + "」名下还有 " + inUse
+ " 张未删除表单,先把表单迁走或删除再删注册行(契约 v2 §5.2 在用禁删)");
}
row.setIsDeleted(1);
appRepository.save(row);
log.info("[app 注册] 软删 {}(sch_id={})", row.getAppCode(), row.getSchId());
return toView(row);
}
// ==================== 校验与定位 ====================
/**
* 按 code 定位<b>本域内</b>的在用行。
*
* <p>清单会显示平台级行,但改/删不动它({@code sch_id} 必须等于本次操作域);他校行与"不存在"
* 同一口径回答——注册表按 code 寻址,code 是半公开的串,"存在但你不是它的主人"这句话本身就是信息。</p>
*/
private AppRegistry requireInDomain(Long domain, String rawAppCode) {
String appCode = requireCode(rawAppCode);
AppRegistry row = appRepository.findByAppCode(appCode)
.filter(a -> Integer.valueOf(0).equals(a.getIsDeleted()))
.orElse(null);
if (row == null || !row.getSchId().equals(domain)) {
throw new BizException(ErrorCode.PARAM_ERROR, "app 不存在:" + appCode);
}
return row;
}
private static String requireCode(String raw) {
String clean = raw == null ? null : raw.trim();
if (clean == null || clean.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "appCode 必填");
}
if (Form.CENTER_APP_CODE.equalsIgnoreCase(clean)) {
// 大小写也算撞名:form.app_code 的缺省值就是这个串,注册它等于把中心表单划进某个应用
throw new BizException(ErrorCode.PARAM_ERROR, "form-center 是中心保留的 app_code,不可注册");
}
if (!APP_CODE_PATTERN.matcher(clean).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR, "appCode 需为 2~64 位小写 slug(^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$):" + clean);
}
return clean;
}
private static String requireName(String raw) {
String clean = raw == null ? null : raw.trim();
if (clean == null || clean.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "应用名称 name 必填");
}
if (clean.length() > NAME_MAX) {
throw new BizException(ErrorCode.PARAM_ERROR, "应用名称过长(≤" + NAME_MAX + ")");
}
return clean;
}
private AppModels.AppView toView(AppRegistry row) {
AppModels.AppView view = new AppModels.AppView();
view.setAppCode(row.getAppCode());
view.setName(row.getName());
view.setSchId(row.getSchId());
view.setEntryUrl(entryUrl(row.getAppCode()));
return view;
}
/**
* 应用入口链接(§5.2"由中心生成",复制后贴去 UserBase 建应用)。
*
* <p>生产形态 {@code /form-center/front/#/app/{appCode}/};本地后端没有 context-path,前端跑在
* 根路径上({@code host.config.ts} 的"seg.length&gt;=2 才有 ctx 段"是同一条推导),这时不套
* {@code /front},否则复制出来的链接在 dev 里 404。</p>
*/
private String entryUrl(String appCode) {
String prefix = contextPath == null || contextPath.trim().isEmpty() ? "" : contextPath.trim();
return prefix + (prefix.isEmpty() ? "/" : "/front") + "#/app/" + appCode + "/";
}
}
@@ -0,0 +1,72 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.model.FormAccessModels.ConsumerPrincipal;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.appauth.AppSecretProvider;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 消费应用身份解析(v3 §1,TASK_1):把"平台验签后的 appKey"换成一个不可自报的主体对象。
*
* <p><b>身份只有一个来源</b>:调用方必须带 {@code X-App-Key/X-Timestamp/X-Signature} 三件套,
* 由平台 {@code AppAuthFilter} 验签通过后才把 appKey 写进请求属性 {@code yh.app.auth}。
* 本类只转读那个属性,因此"没签名"与"签名不是我们的"在同一处就被关死——
* 请求里另有一个自由输入的 {@code appCode},它只是资源筛选项,<b>永远不构成身份</b>(v3 §1)。</p>
*
* <p>签名密钥可来自trusted-apps,但它不证明UserBase登记事实。
* 因此另经OpenApiAppClient查询UserBase注册信息;不可达或查不到均拒绝。
* 这里只取存在性,密钥值不出本类。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ConsumerPrincipalResolver {
private static final int APP_KEY_MAX = 64;
private final AppSecretProvider appSecretProvider;
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient;
/**
* @param appKey 取自已验签的请求属性;空/超格式/注册表查不到一律 {@code UNAUTHORIZED}
*/
public ConsumerPrincipal resolveSignedCaller(String appKey) {
String clean = appKey == null ? null : appKey.trim();
if (clean == null || clean.isEmpty() || clean.length() > APP_KEY_MAX) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已验签的应用身份(X-App-Key 三件套)");
}
if (appSecretProvider.getSecret(clean) == null || !isRegisteredAppKey(clean)) {
log.warn("[consumer ACL] appKey 在平台密钥注册表查不到,拒绝:appKey={}", clean);
throw new BizException(ErrorCode.UNAUTHORIZED, "已验签应用身份不可用");
}
return new ConsumerPrincipal(clean);
}
/**
* 管理面写入前的身份核验(v3 §2):owner/grant 里填的 appKey 必须是 UserBase 注册过的应用,
* 否则"绑一个不存在的 appKey"会把资源挂到空气上。只判存在,不回显密钥。
*/
public boolean isRegisteredAppKey(String appKey) {
String clean = appKey == null ? null : appKey.trim();
if (clean == null || clean.isEmpty() || clean.length() > APP_KEY_MAX) {
return false;
}
if (appRegistryClient == null || !clean.matches("[0-9]+")) {
return false;
}
try {
com.fasterxml.jackson.databind.JsonNode registered = appRegistryClient.getAppByKey(clean);
return registered != null && !registered.isNull()
&& clean.equals(registered.path("appKey").asText());
} catch (RuntimeException e) {
// Registry unavailable is not proof of registration; never fall back to trusted-apps.
log.warn("[consumer ACL] UserBase应用注册核验不可用 appKey={}", clean);
return false;
}
}
}
@@ -1,8 +1,6 @@
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormModuleKeys;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope; import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
@@ -11,105 +9,116 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围, * 数据范围解析器(契约 v2 §2.3 / D4):把"会话用户 + 这张表单"解析成<b>两档</b>可见集,
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。 * 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
* *
* <p><b>解析规则(优先级)</b>:{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵},
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
*
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
*
* <p><b>身份默认矩阵</b>(契约 §8,行=roleId、列=表单面向人群):</p>
* <pre> * <pre>
* 面向学生(1) 面向教师(2) * ALL =该表单全部行 持有者:创建人(C16)∪ form_admin ∪ 校管理员(∪ 超管)
* 7/2 全部 全部 * SELF =我提交的 ∪ 关于我的 一切 CAS 会话用户天然持有
* 1 任教班级 → 一期实现器缺席=仅本人提交 仅本人提交
* 3 关于我 仅本人提交(该格契约写"不授填报模块行,进不来",
* 数据面按最保守的兜底值处理)
* 4/6/5 仅本人提交 仅本人提交
* </pre> * </pre>
* *
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(授权模块行), * <p>三条与旧模型不同的地方,逐条对应《契约v2》§19 作废-1/5:</p>
* 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求), * <ul>
* 但一个是 path、一个是 customId,不要混用。</p> * <li><b>身份默认矩阵整体下线</b>——原"roleId × 面向人群"逐格取值、多来源取最宽、
* "全不命中回落仅本人提交"三段全部删除。回落分支之所以不需要:登录用户天然持有 SELF,
* 空集只可能出现在无会话场景(匿名通道不经本解析);</li>
* <li><b>SCHOOL 不再是独立范围值</b>——它变成 ALL 档内的租户限定变体({@link Resolved#platformScopedAll()}),
* 只对平台级表单({@code sch_id=0})生效;</li>
* <li><b>创建人自动 ALL</b>(D6/G1)——运行时并集判定,不落 {@code form_admin} 行,两张表保持"纯授权"语义。</li>
* </ul>
*
* <p><b>fail-closed 三处不回退</b>:未登录即拒;他校表单({@code sch_id} 既非会话校也非 0)不给 ALL;
* 租户限定变体给不出会话校即拒。存量 {@code submitter_sch_id=NULL} 不匹配等值谓词(P84)。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class DataScopeService { public class DataScopeService {
/** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */
public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all";
/** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */
private static final int SUPER_ADMIN_TYPE = 1;
private static final int ROLE_TEACHER = 1;
private static final int ROLE_SCHOOL_LEADER = 2;
private static final int ROLE_STUDENT = 3;
private static final int ROLE_ADMIN = 7;
private final ModuleTreeSource moduleTreeSource;
private final List<AnswerScopeImplementor> implementors; private final List<AnswerScopeImplementor> implementors;
private final AdminScopeSource adminScopeSource;
private final FormAccessPolicy accessPolicy;
/** /**
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。 * 该会话用户在这张表单上的可见档集合。
* *
* @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理 * @param form 目标表单({@code schId} 决定 ALL 是否走租户限定变体,{@code uuid}/{@code creatorId}
* 决定创建人与 form_admin 命中);null=没有表单域可判,只给 SELF
* @return 永不含空集、永不为 null;至少持有 SELF
*/ */
public Set<DataScope> resolve(BoaUser user, Form form) { public Resolved resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
} }
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) { if (form == null || form.getSchId() == null) {
return EnumSet.of(DataScope.ALL); return Resolved.selfOnly();
} }
Set<DataScope> scopes = new LinkedHashSet<>(); // 超管与创建人不受域约束:平台表单的全量数据本来就归创建人(D9)
// 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认) if (FormAccessPolicy.isSuperAdmin(user) || isCreator(user, form)) {
if (hasScopeAllAnchor(user)) { return Resolved.fullAll();
scopes.add(DataScope.ALL);
} }
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽) if (!adminScopeSource.isFormAdmin(form.getUuid(), user.getId()) && !accessPolicy.isSchoolAdmin(user)) {
int audience = form == null || form.getAudience() == null return Resolved.selfOnly();
? FormEnums.Audience.STUDENT.getCode() : form.getAudience();
for (Integer roleId : identityRoleIds(user)) {
scopes.add(defaultScope(roleId, audience));
} }
if (scopes.isEmpty()) { // 两表命中后给哪种 ALL 形态由表单归属域决定(判据单点在 FormAccessPolicy,V58)
scopes.add(DataScope.SELF_SUBMITTED); return allForDomain(form, FormAccessPolicy.sessionSchId(user));
}
if (scopes.contains(DataScope.ALL)) {
// 命中"全部"即收敛:其余范围是它的真子集,并集里留着只会让读路径多写几个 OR
return EnumSet.of(DataScope.ALL);
}
return EnumSet.copyOf(scopes);
} }
/** /**
* 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里, * 授权命中后在这张表单上的 ALL 形态:本校表单=全量,平台级表单=租户限定变体,他校表单=不给。
* 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。 *
* <p>平台级表单上不给全量是<b>刻意的</b>:§2.3 的变体只点名校管理员,但 D9/V57 的不变式是
* "平台表单全量归创建人(超管)、跨校学生数据不因授权而可见"——form_admin 授权同样落在一张跨校
* 资产上,给全量就等于开一个跨校读口子(T10 D2=B 加 {@code submitter_sch_id} 列的动因原样成立)。
* 该差额登记为问题单,等裁决而不是静默放宽。</p>
*/ */
public Set<DataScope> selectableScopes() { private static Resolved allForDomain(Form form, Long ownSch) {
Set<DataScope> result = new LinkedHashSet<>(); long formSch = form.getSchId();
for (AnswerScopeImplementor implementor : implementors) { if (ownSch != null && formSch == ownSch) {
if (implementor.selectable()) { return Resolved.fullAll();
result.add(implementor.scope());
} }
if (formSch == Form.PLATFORM_SCH_ID) {
return ownSch == null ? Resolved.selfOnly() : Resolved.platformScopedAllVariant();
} }
return result; // 他校表单:不给 ALL(跨校授权不成立;D9 的平台级例外只针对 sch_id=0)
return Resolved.selfOnly();
} }
/** 范围 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */ /**
* 创建人判定(C16):{@code creator_id}==会话 uid <em>或</em> {@code create_by}==会话账号名。
*
* <p>兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。</p>
*/
/**
* 创建人判定(C16):{@code creator_id}==会话 uid <em>或</em> {@code create_by}==会话账号名。
*
* <p>兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。</p>
*
* <p>公开给 {@link FormAdminGuard} 复用(C4①"定义域写含该表单创建人"与 ALL 档
* 必须是同一条判定,两处各写一份就会漂移)。</p>
*/
public static boolean isCreator(BoaUser user, Form form) {
if (user == null || user.getId() == null || form == null) {
return false;
}
if (form.getCreatorId() != null) {
return form.getCreatorId().longValue() == user.getId().longValue();
}
String account = FormAccessPolicy.sessionAccount();
return account != null && !account.trim().isEmpty() && account.trim().equals(form.getCreateBy());
}
/** 档 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
public AnswerScopeImplementor implementorOf(DataScope scope) { public AnswerScopeImplementor implementorOf(DataScope scope) {
for (AnswerScopeImplementor implementor : implementors) { for (AnswerScopeImplementor implementor : implementors) {
if (implementor.scope() == scope) { if (implementor.scope() == scope) {
@@ -119,48 +128,56 @@ public class DataScopeService {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope); throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
} }
/** 是否命中 fc:scope:all 权限锚点(只认 path 空节点,P47;见 FormModuleKeys#anchorCustomIds) */
private boolean hasScopeAllAnchor(BoaUser user) {
return FormModuleKeys.anchorCustomIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
}
/** /**
* 参与矩阵解析的身份:{@code roleIds}(用户全部正常态角色)并上会话 {@code currentRole.roleId} * 解析输出=二档布尔集(§2.3 原文形态),外加 ALL 是否走租户限定变体。
* ——会话身份理论上必在 roleIds 内,并一次是防 UserBase 两侧不一致时漏掉当前身份那一格。 * {@link #scopes()} 是喂给查询规格与出参的枚举视图,两者同一份事实。
*/ */
private List<Integer> identityRoleIds(BoaUser user) { public static final class Resolved {
List<Integer> ids = new ArrayList<>();
if (user.getRoleIds() != null) { private static final Resolved SELF_ONLY = new Resolved(false, true, false);
ids.addAll(user.getRoleIds()); private static final Resolved FULL_ALL = new Resolved(true, true, false);
} private static final Resolved PLATFORM_SCOPED_ALL = new Resolved(true, true, true);
Integer current = user.getCurrentRole() == null ? null : user.getCurrentRole().getRoleId();
if (current != null && !ids.contains(current)) { private final boolean all;
ids.add(current); private final boolean self;
} private final boolean platformScopedAll;
return ids.isEmpty() ? Collections.<Integer>emptyList() : ids;
private Resolved(boolean all, boolean self, boolean platformScopedAll) {
this.all = all;
this.self = self;
this.platformScopedAll = platformScopedAll;
} }
/** 矩阵取格(任教班级在一期落到"仅本人提交",见类注释表格) */ static Resolved selfOnly() {
private DataScope defaultScope(Integer roleId, int audience) { return SELF_ONLY;
boolean studentFacing = FormEnums.Audience.STUDENT.getCode() == audience;
if (roleId == null) {
return DataScope.SELF_SUBMITTED;
} }
if (roleId == ROLE_ADMIN || roleId == ROLE_SCHOOL_LEADER) {
return DataScope.ALL; static Resolved fullAll() {
return FULL_ALL;
} }
if (roleId == ROLE_TEACHER) {
if (!studentFacing) { static Resolved platformScopedAllVariant() {
return DataScope.SELF_SUBMITTED; return PLATFORM_SCOPED_ALL;
} }
// 面向学生:矩阵值=任教班级,一期实现器缺席 → 生效值=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
log.debug("[数据范围] 教师×面向学生:任教班级一期未开放,按矩阵注记生效为仅本人提交"); public boolean all() {
return DataScope.SELF_SUBMITTED; return all;
} }
if (roleId == ROLE_STUDENT && studentFacing) {
return DataScope.ABOUT_ME; public boolean self() {
return self;
}
/** ALL 档是否收窄成"本校提交"(D9;仅 {@code sch_id=0} 的表单可能为 true) */
public boolean platformScopedAll() {
return platformScopedAll;
}
public Set<DataScope> scopes() {
if (all) {
return EnumSet.of(DataScope.ALL);
}
return self ? Collections.singleton(DataScope.SELF) : Collections.<DataScope>emptySet();
} }
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
return DataScope.SELF_SUBMITTED;
} }
} }
@@ -0,0 +1,229 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.formcenter.entity.Answer;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import javax.annotation.PostConstruct;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 答卷事件推送(契约 v2 §6.2,T17,验收 <b>V62/V63/V64</b>)。
*
* <p><b>三条裁决决定了这个类的形状</b>(契约把它们标成"不可协商"):</p>
* <ol>
* <li><b>MQ 是通知通道,不是数据通道</b>——信封十字段闭集(V63),<b>不含值文档</b>。值文档只有
* 签名验过的拉取端点那一份;把值塞进总线等于把读面从"鉴权端点"扩大到"能连上 broker 的任何人";</li>
* <li><b>拉取是事实源,MQ 只降延迟</b>——所以这里<b>不写 outbox、不配死信</b>,发布失败只 warn
* (丢的那条由消费方的 5 分钟增量拉取补回,V62 的"同步单向"也因此不会被消息顺序反向污染);</li>
* <li><b>可选依赖</b>——{@code RABBITMQ_HOST} 未注入 → 整体 no-op + 启动一条日志,服务照跑(V64;
* 照 data-redis 那条先例:引依赖但不引运行期强耦合,配套 yml 关 rabbit 健康探针)。</li>
* </ol>
*
* <p><b>发布时机=事务提交后</b>({@code afterCommit}):回滚掉的事务不能留下"已发布但不存在"的事件——
* 消费方按 {@code answer_uuid} 去拉会拉不到,而它没有理由重试。同步器未激活时(无事务上下文,
* 例如单测直接调)就地发布,不为测试特殊开事务。</p>
*
* <p>exchange 惰性声明({@code durable topic fc.events.v1}):第一次真要发的时候 declare 一次,
* 之后不再重复。未启 broker 的环境永远走不到这一行,因此也不需要 {@code RabbitAdmin} 的自动声明链路。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FcEventPublisher {
/** 拓扑:topic exchange,名与 durable 都由契约定死(§6.2) */
public static final String EXCHANGE = "fc.events.v1";
/** 平台时区(与 hibernate.jdbc.time_zone / jackson.time-zone 同一条),信封时间戳按它给偏移量 */
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter OCCURRED_AT = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
/**
* RabbitTemplate 走 {@link ObjectProvider}:本应用"可以完全不配 MQ",
* 直接注入会在依赖缺失时把上下文拖垮(而可选依赖的整个前提就是"哪天 broker 就绪哪天开")。
*/
private final ObjectProvider<RabbitTemplate> templates;
// Local serialization leaves the shared RabbitTemplate converter and v2 channel unchanged.
private final ObjectMapper eventMapper = new ObjectMapper();
@Value("${fc.events.enabled:false}")
private boolean eventsEnabled;
/** Explicit events switch plus broker host; independent of the v2 snapshot outbox. */
@Value("${spring.rabbitmq.host:}")
private String host;
private final AtomicBoolean exchangeDeclared = new AtomicBoolean(false);
@PostConstruct
public void logState() {
if (enabled()) {
log.info("[MQ] 事件发布启用(exchange={},host={})", EXCHANGE, host);
} else {
log.info("[MQ] events.enabled=false 或未配置 broker,事件发布禁用;拉取仍是事实源");
}
}
public boolean enabled() {
return eventsEnabled && host != null && !host.trim().isEmpty();
}
// ==================== 三个发布点 ====================
/** 提交管线写库段之后(手工/代录/导入同管线,三者自动都在这,§3.4/V60) */
public void answerCreated(Answer answer) {
publish(answer, "created");
}
/** 编辑写库段同点位(非冻结表单才可能走到这里——冻结双通道同关,V61) */
public void answerUpdated(Answer answer) {
publish(answer, "updated");
}
/**
* 软删(墓碑)。
*
* <p>写库点有两处实体 save(管理面 {@code doSoftDelete}(单条与批量共用)与提交人侧
* {@code softDeleteBySubmitter}),契约要求"软删公共出口收敛一处"——本方法就是那一处:
* 类型、routing key、信封组装只在这里定义一次,两个写库点各调它一次。</p>
*/
public void answerTombstoned(Answer answer) {
publish(answer, "tombstoned");
}
// ==================== 内部 ====================
private void publish(Answer answer, String action) {
if (!enabled() || answer == null) {
return;
}
// 事件内容在提交点前就定死(快照字段),afterCommit 只负责"发",不再回读实体——
// 那时事务已结束,实体可能已被后续操作改写,也可能压根不在同一持久化上下文里
Envelope envelope = envelopeOf(answer, action);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
send(envelope, action);
}
});
} else {
send(envelope, action);
}
}
private void send(Envelope envelope, String action) {
try {
byte[] body = eventMapper.writeValueAsBytes(envelope);
RabbitTemplate template = templates.getIfAvailable();
if (template == null) {
log.warn("[MQ] 已配置 RABBITMQ_HOST 但容器里没有 RabbitTemplate,跳过发布(不影响主流程)");
return;
}
declareExchangeOnce(template);
MessageProperties properties = new MessageProperties();
properties.setContentType(MessageProperties.CONTENT_TYPE_JSON);
properties.setContentEncoding("UTF-8");
properties.setMessageId(envelope.getEventId());
properties.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
template.send(EXCHANGE, routingKey(envelope.getAppCode(), action), new Message(body, properties));
} catch (Exception e) {
// 裁决 2:broker 故障绝不阻断提交。消费方以游标拉取为事实源,这条事件丢了由下一轮增量补回
log.warn("[MQ] 事件发布失败(忽略,拉取兜底):type={} answer={} cause={}",
envelope.getType(), envelope.getAnswerUuid(), e.toString());
}
}
private void declareExchangeOnce(RabbitTemplate template) {
if (!exchangeDeclared.compareAndSet(false, true)) {
return;
}
try {
template.execute(channel -> {
channel.exchangeDeclare(EXCHANGE, "topic", true);
return null;
});
} catch (Exception e) {
// 声明失败要允许下次再试(broker 抖动一次就永久静默是不可接受的)
exchangeDeclared.set(false);
throw e;
}
}
/** routing key:{@code fc.answer.{app_code}.{created|updated|tombstoned}}(§6.2 队列绑定约定) */
public static String routingKey(String appCode, String action) {
return "fc.answer." + appCode + "." + action;
}
private Envelope envelopeOf(Answer answer, String action) {
Envelope envelope = new Envelope();
envelope.setEventId(UUID.randomUUID().toString());
envelope.setType("fc.answer." + action);
envelope.setOccurredAt(OffsetDateTime.now(ZONE).format(OCCURRED_AT));
envelope.setAppCode(answer.getAppCode());
envelope.setSchId(answer.getSchId());
envelope.setFormUuid(answer.getFormUuid());
envelope.setFormCode(answer.getCode());
envelope.setAnswerUuid(answer.getUuid());
envelope.setVersion(answer.getVersion());
envelope.setAnswerNo(answer.getAnswerNo());
return envelope;
}
/**
* 消息信封(§6.2 字段闭集,V63:<b>多一个字段就该被当成违约</b>,所以这里逐字段标
* {@link JsonProperty} 而不是靠命名策略——snake_case 是契约原文,不是本类偏好)。
*/
@Getter
@Setter
public static class Envelope {
@JsonProperty("event_id")
private String eventId;
@JsonProperty("type")
private String type;
@JsonProperty("occurred_at")
private String occurredAt;
@JsonProperty("app_code")
private String appCode;
@JsonProperty("sch_id")
private Long schId;
@JsonProperty("form_uuid")
private String formUuid;
@JsonProperty("form_code")
private String formCode;
@JsonProperty("answer_uuid")
private String answerUuid;
@JsonProperty("version")
private Integer version;
@JsonProperty("answer_no")
private String answerNo;
}
}
@@ -0,0 +1,185 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormAdmin;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.MyModels;
import com.yunhai.formcenter.repository.FormAdminRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 「我可填」与「我管理的」两张清单({@code GET /api/form/my/fillable} 契约 v2 C2 行形状、
* {@code GET /api/form/my/managed} C3):把"这个人能填哪些表、管哪些表"各算成一张列表——
* 数据源不再是 UserBase 模块树,两支路都由中心自解析(契约 v2 §2.4/D5)。
*
* <p><b>四步,一步不多</b>:</p>
* <ol>
* <li><b>取域</b>:会话校 ∪ 平台级({@code sch_id=0}),只认 {@code status=1} 已发布且未软删的头表;</li>
* <li><b>遮蔽去重</b>:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落——
* 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍:
* 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);</li>
* <li><b>逐行过 audience 门</b>:登录身份类型 ∩ {@code form.audience}=∅ 的行不入集。
* 这一条与 {@link AnswerService} 提交时的 audience 门<b>同一个公式</b>——
* "列表里有"与"提交能过"不可能不一致(原 P95 的 {@code canFill}/{@code myTier} 随之消失:
* 入集即等价于可填,不再有"进了列表但不能填"的中间态,§19 作废-3);</li>
* <li><b>排序</b>:deadline 升序、null(不限)在后、同档按 code。</li>
* </ol>
*
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(量级假设,报告已记),
* 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。</p>
*
* <p><b>{@code ANONYMOUS} 不参与入集</b>:匿名填报人不看列表,持 URL 直达(C2)。
* 本类也不看数据范围({@link DataScopeService}):列表里出现某张表单,不代表进去能看到别人的行。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FillableFormService {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormRepository formRepository;
private final FormAdminRepository formAdminRepository;
/**
* 我可填清单。
*
* @param appCode 可选;空/缺省=全部应用(中心 + 所有快应用),非空=只要该 app_code 下的表单
* @return 已按 deadline/code 排好序的行集;无权限的人拿到的是空集而不是异常
*/
public List<MyModels.FillableRow> listFillable(BoaUser user, String appCode) {
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
String cleanApp = appCode == null ? "" : appCode.trim();
List<Long> domain = Form.PLATFORM_SCH_ID == schId
? Collections.singletonList(Form.PLATFORM_SCH_ID)
: Arrays.asList(schId, Form.PLATFORM_SCH_ID);
// 先按 (app_code, code) 分组,再逐组走遮蔽规则——resolve 是"一个 code 一次判定",
// 列表要把同一条判定摊到多个 code 上,分组就是这条摊法(不是另写一份遮蔽逻辑)
Map<String, List<Form>> groups = new LinkedHashMap<>();
for (Form form : formRepository.findPublishedInDomain(domain)) {
if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
continue;
}
String key = form.getAppCode() + "#" + form.getCode();
List<Form> bucket = groups.get(key);
if (bucket == null) {
bucket = new ArrayList<>();
groups.put(key, bucket);
}
bucket.add(form);
}
List<Form> visible = new ArrayList<>();
for (List<Form> bucket : groups.values()) {
Form winner = FormDefService.pickByShadowing(bucket, schId);
if (winner == null) {
continue;
}
if (winner.getCurrentVersion() == null) {
// status=1 却没有版本指针=定义坏状态,resolve 会以"当前发布版本行缺失"拒绝,
// 这里同样不给入口(宁缺勿滥:列表里出现点了报 500 的表单比少一张更糟)
log.warn("[我可填] 表单 {}/{} 已发布但当前版本指针为空,跳过", winner.getAppCode(), winner.getCode());
continue;
}
visible.add(winner);
}
Collections.sort(visible, Comparator
.comparing((Form form) -> form.getDeadline() == null ? LocalDateTime.MAX : form.getDeadline())
.thenComparing(form -> form.getCode() == null ? "" : form.getCode()));
List<MyModels.FillableRow> rows = new ArrayList<>();
Set<FormEnums.Audience> identities = FormAccessPolicy.identityTypes(user);
for (Form form : visible) {
// 入集判定=audience 门的列表形态(§3.1 同一公式,不留两套口径):身份类型 ∩ 面向人群非空
if (Collections.disjoint(identities, FormEnums.Audience.parse(form.getAudience()))) {
continue;
}
rows.add(toRow(form));
}
return rows;
}
private static MyModels.FillableRow toRow(Form form) {
MyModels.FillableRow row = new MyModels.FillableRow();
row.setCode(form.getCode());
row.setName(form.getName());
row.setAppCode(form.getAppCode());
row.setAudience(FormEnums.Audience.namesOf(form.getAudience()));
row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS));
row.setFillLimit(form.getFillLimit());
row.setGroupTag(form.getGroupTag());
return row;
}
/**
* 「我管理的」清单(契约 v2 C3,菜单自解析的"管理"支路):创建人 ∪ form_admin 命中的行。
*
* <p>取域与「我可填」同为"会话校 ∪ 平台级",但<b>不做遮蔽去重、不限生命周期</b>
* (理由见 {@link MyModels.ManagedRow} 的类注释)。排序沿仓库的 {@code update_time desc}
* ——管理动作关心"最近碰过哪张",不像填报清单那样有截止时间这个天然主序。</p>
*
* @param appCode 可选;非空=只要该应用下的行
*/
public List<MyModels.ManagedRow> listManaged(BoaUser user, String appCode) {
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
String cleanApp = appCode == null ? "" : appCode.trim();
List<Long> domain = Form.PLATFORM_SCH_ID == schId
? Collections.singletonList(Form.PLATFORM_SCH_ID)
: Arrays.asList(schId, Form.PLATFORM_SCH_ID);
List<String> administered = new ArrayList<>();
for (FormAdmin grant : formAdminRepository.findByUserId(user.getId())) {
if (grant.getFormUuid() != null) {
administered.add(grant.getFormUuid());
}
}
List<MyModels.ManagedRow> rows = new ArrayList<>();
for (Form form : formRepository.findLiveInDomain(domain)) {
if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
continue;
}
List<String> roles = new ArrayList<>();
if (DataScopeService.isCreator(user, form)) {
roles.add("creator");
}
if (form.getUuid() != null && administered.contains(form.getUuid())) {
roles.add("form_admin");
}
if (roles.isEmpty()) {
continue;
}
MyModels.ManagedRow row = new MyModels.ManagedRow();
row.setFormUuid(form.getUuid());
row.setStatus(form.getStatus());
MyModels.FillableRow base = toRow(form);
row.setCode(base.getCode());
row.setName(base.getName());
row.setAppCode(base.getAppCode());
row.setAudience(base.getAudience());
row.setDeadline(base.getDeadline());
row.setFillLimit(base.getFillLimit());
row.setGroupTag(base.getGroupTag());
row.setRole(roles);
rows.add(row);
}
return rows;
}
}
@@ -0,0 +1,166 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 会话身份推导的<b>唯一出口</b>(契约 v2 §2.5 / T12):把"这个会话是谁、属于哪个校、算哪几种身份"
* 从原先六处各自抄一遍的私有实现收编成一处,角色魔法数字同样单点定义(B3/B5)。
*
* <p><b>本批次(T12)只做收编,不做语义</b>:判定逻辑、拒绝文案与各调用点原有实现逐字等价,
* 语义切换(两档范围、audience 门、校管理员判定)在 T13 于本门面之上进行。</p>
*
* <p><b>形态说明</b>:会话/身份推导是<b>静态</b>单点方法——原实现本就是各调用类的 private static
* (纯字段换算,无协作者),做成实例方法要给每个消费方加构造器入参,会连带改动 T1~T11 期间
* 直接 new 这些服务的单测,与"零语义变化"批次的判据冲突。需要协作者(两表)的
* {@link #isSchoolAdmin(BoaUser)} 保持实例方法,由 Spring 装配消费方注入。</p>
*
* <p><b>红线(V58)</b>:全库只允许本类持有角色数字常量与校领导清单;新守卫一律调本门面,
* 不许再抄 {@code type==1} / {@code roleIds.contains(2)} 这类字面量。</p>
*/
@Component
@RequiredArgsConstructor
public class FormAccessPolicy {
/** 超管用户类型(UserBase {@code UserTypeEnum.SUPER_ADMIN};不是 roleId) */
public static final int SUPER_ADMIN_TYPE = 1;
/** 平台标准角色:教师 */
public static final int ROLE_TEACHER = 1;
/** 平台标准角色:校领导 */
public static final int ROLE_SCHOOL_LEADER = 2;
/** 平台标准角色:学生 */
public static final int ROLE_STUDENT = 3;
/** 平台标准角色:管理员 */
public static final int ROLE_ADMIN = 7;
/**
* 校领导角色清单——<b>临时值</b>,P-B 待 UserBase 正名(G2 裁决:现网三处既用值升格为单点常量,
* 正名后只改这一处,调用面与语义零变化)。
*/
public static final Set<Integer> SCHOOL_LEADER_ROLES =
Collections.unmodifiableSet(new HashSet<>(Arrays.asList(ROLE_SCHOOL_LEADER, ROLE_ADMIN)));
private final AdminScopeSource adminScopeSource;
/** 会话学校;无当前身份、无学校归属或会话为空 → null(调用侧自行决定拒绝口径) */
public static Long sessionSchId(BoaUser user) {
Integer schId = user == null || user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
return schId == null ? null : Long.valueOf(schId);
}
/**
* 会话学校(必须给得出的形态):未登录 {@code UNAUTHORIZED},无学校归属 {@code FORBIDDEN}。
*
* @param noSchoolMessage 各调用面原有文案(T12 统一文案未开始,逐点沿用)
*/
public static Long requireSessionSchId(BoaUser user, String noSchoolMessage) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
Long schId = sessionSchId(user);
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, noSchoolMessage);
}
return schId;
}
/** 会话用户 id(平台全局身份,非本库自增主键);未登录为 null */
public static Integer uid(BoaUser user) {
return user == null ? null : user.getId();
}
/**
* 会话账号名——与 {@code create_by}/{@code update_by} <b>同一个事实源</b>
* ({@code FormCenterAuditListener} 落列时取的就是它),所以"创建人兜底判定"(C16)
* 比的是同一条字符串,不是两个系统里恰好相等的两个字段。
*
* <p>{@code BoaUser} 上另有一个 {@code account},但它由 UserBase 组织接口下发、与 token 侧
* 账号名不是同一处写入;拿它比 {@code create_by} 会引入"两边口径不同就静默判不出创建人"的隐患。</p>
*/
public static String sessionAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
/** 超管(UserBase 用户类型判定;不是 roleId) */
public static boolean isSuperAdmin(BoaUser user) {
return user != null && user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
}
/**
* 参与身份解析的角色集合:{@code roleIds}(全部正常态角色)并上会话 {@code currentRole.roleId}。
* 并一次是防 UserBase 两侧不一致时漏掉当前身份那一格(原 {@code DataScopeService} 口径)。
*/
public static List<Integer> identityRoleIds(BoaUser user) {
if (user == null) {
return Collections.emptyList();
}
List<Integer> ids = new ArrayList<>();
if (user.getRoleIds() != null) {
ids.addAll(user.getRoleIds());
}
Integer current = user.getCurrentRole() == null ? null : user.getCurrentRole().getRoleId();
if (current != null && !ids.contains(current)) {
ids.add(current);
}
return ids;
}
/**
* 会话持有的<b>填报身份类型</b>(多身份取并集):含学生角色(3)→{@code STUDENT}、含教师角色(1)→{@code TEACHER}。
*
* <p>返回值永不含 {@code ANONYMOUS}——匿名只对匿名通道生效,登录会话不可能持有它(C1)。
* 家长(4)/局领导(5)/工作号(6) 与未知角色不产生任何填报身份。</p>
*/
public static Set<FormEnums.Audience> identityTypes(BoaUser user) {
Set<FormEnums.Audience> types = EnumSet.noneOf(FormEnums.Audience.class);
for (Integer roleId : identityRoleIds(user)) {
if (roleId == null) {
continue;
}
if (roleId == ROLE_STUDENT) {
types.add(FormEnums.Audience.STUDENT);
} else if (roleId == ROLE_TEACHER) {
types.add(FormEnums.Audience.TEACHER);
}
}
return types;
}
/**
* 是否<b>校管理员</b>(契约 v2 §2.2):超管 ∪ 校领导角色清单命中 ∪ {@code school_admin} 表命中会话校。
*
* <p>T12 仅落定义,消费方在 T13 的两档范围解析与管理面守卫切换里接入;
* {@code school_admin} 表被删空时天然角色仍是兜底。</p>
*/
public boolean isSchoolAdmin(BoaUser user) {
if (user == null) {
return false;
}
if (isSuperAdmin(user)) {
return true;
}
for (Integer roleId : identityRoleIds(user)) {
if (roleId != null && SCHOOL_LEADER_ROLES.contains(roleId)) {
return true;
}
}
Long schId = sessionSchId(user);
return schId != null && user.getId() != null && adminScopeSource.isSchoolAdmin(schId, user.getId());
}
}

Some files were not shown because too many files have changed in this diff Show More