feat(definition): T2 后端·定义域——版本模型、schema 校验器、选项字典与另存副本
build / build-push (push) Successful in 2m22s

定义与答卷物理分离的第一步:把"契约"落进可执行代码,让后续答卷域只需消费不必再解释。

- form/form_version 头表+版本行:已发布行零写路径(改版=v+1 新行,老行逐字节冻结),
  升版期间头表指针不动,使契约 §5 的钉版与 §3.2 的两层缓存声明天然成立;uuid 建草稿即生成
- FormSchemaValidator:19 项属性白名单、14/11 控件、options 只认 {label,value} 数组或
  dict:{code} 引用(字符串数组被拒)、searchable 只出显式声明字段、跨版本同 key 不得换 type
  (否则 answer_index 分类型落值的语义与"删字段后历史仍可筛"当场失效)
- 选项字典两级:管理面同层精确匹配(校级改不到平台级)、解析面 {会话校,0} 且本校遮蔽不回落;
  value 物理不可变而 label 可改;软删条目仍进提交放行集;引用是活引用、只在读路径内联展开
- 表单级配置(audience/fill_limit/deadline 补 23:59:59/报名号/状态定义)留在头表,不随版本快照
- 另存为副本:新 code+新 uuid+version 1、schema 深拷贝且字典引用随行,源表只读
- 出入参不出现库内 id;手写 DDL 落 sql/t2_definition_domain.sql 并按 MySQL 5.7 复核(无 native SQL)

自验:mvn -B -o clean package 绿(67 测试,含 27 条打真实 MySQL 的 IT),逐 V 证据与 9 条
实现期问题单见 docs/T2-report.md
This commit is contained in:
chenxing
2026-10-14 15:23:35 +08:00
parent 5c5abe1e91
commit 03f6a971c3
26 changed files with 4265 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
# T2 执行说明 · 后端·定义域
> 任务书:《表单中心任务书-Qoder-20261014.md》v1.1 §T2|执行日期:2026-10-14|执行方:Qoder
> 输入权威:方案 v1.4(§4.2/§5.1/§5.3/§5.4/§7/§9)+ 契约 v1.0(§1/§2/§2.1/§10)+ 执行手册 §5 裁决 **P5/P2/P10**
> 前置:T1 骨架已验收通过。本轮**未改动**任何已验收产出(pom.xml / application.yml / BaseEntity / FormBaseController / CORS / Dockerfile / CI 全部零改动,无新增依赖)。
---
## 1. 做了什么
### 1.1 落包清单(19 个主类 + 6 个测试类 + 1 份 DDL 脚本)
| 包 | 新增 | 承担 |
| --- | --- | --- |
| `entity` | `Form` / `FormVersion` / `FormDict` / `FormDictItem` | 契约 §10 定义域四表;全部 `extends BaseEntity`(P5:审计四件,实际表=草案+create_by/update_by);无物理外键;`uuid/sch_id/app_code/code/version/value` 等身份列 `updatable=false` |
| `enums` | `FormEnums` | FormStatus 0/1/2、Audience 1/2、FillLimit 0/1、LimitScope(SUBJECT/SUBMITTER 推导结果,不是配置项) |
| `repository` | `FormRepository` / `FormVersionRepository` / `FormDictRepository` / `FormDictItemRepository` | 纯 JPQL/派生查询,**零 native SQL**(P10:没有 CTE/WITH/函数索引可写错的地方);`FormVersionRepository` 不提供任何"改已发布行"的方法 |
| `common` | `FormSchemaValidator` / `FormStatusDefs` / `FormRuntimeRules` | 19 项白名单+14/11 控件+options 契约+searchable+跨版本字段身份;V12 状态定义;截止 23:59:59 与 limitScope/quotaKey(T3 提交与 T7 只读态共用同一份口径) |
| `model` | `FormModels` | 出入参 DTO;**响应体不含库内 `id`**(契约 §1,V5 断言点) |
| `service` | `FormDefService` / `FormDictService` / `FormAdminGuard` | 定义域全部业务写路径 |
| `controller/api` | `FormAdminController`(20 端点)/ `FormResolveController`(1 端点) | 见 §1.5 |
| `sql/` | `t2_definition_domain.sql` | 定义域四表的 MySQL 5.7 兼容 DDL(评审权威 + 生产 ddl-auto=none 时的升级脚本底稿) |
| `local-debug` | `t2-def-smoke.sh` | 手册 §4.2"能脚本化的断言入 local-debug" |
### 1.2 版本模型(V7 的核心取舍)
- **头表 + 版本行**:草稿行与发布行同在 `form_version`,区分依据是 `publish_time`(NULL=草稿)与头表 `current_version` 指针(方案 §5.1)。
- **两种合法写通道,都不触碰已发布内容**:①未发布草稿行的 `schema_json`/`change_log` 直改;②发布动作补 `publish_time`。已发布行**没有任何写路径**(仓库不提供、服务不暴露),`updateDraft` 还有一道 `publishTime != null` 即拒的显式守卫。
- **升版期间旧版仍可填**:已发布表单保存新 schema → 只开 `version=max+1` 的草稿行,头表 `status/current_version` 不动,发布才推指针。于是契约 §5"按填报页加载时解析到的版本校验并钉版"与方案 §3.2 的两层缓存声明天然成立 → 问题单 **P20**。
- **key 稳定性**:`form_version` 是快照、`form` 是头表,`name` 只在头表(改名不升版);`code` 仅"从未发布"可改(契约 §1),改时 bulk update 同步草稿行冗余 `code` 列(`uk_form_ver` 含 code,不同步则 resolve 失配)。
- **表单级配置不进版本快照**(方案 §5.1/§5.4):`audience/fill_limit/deadline/answer_no_enabled/status_defs/group_tag` 都在头表原地改,测试断言"改配置后版本行数仍是 1、v1 schema 逐字节不变"。
### 1.3 schema 校验器(与 svc-hall `FormValidator` 的三处硬修订)
| 契约条目 | 实现 |
| --- | --- |
| 14 类一次冻结 / 一期 11 类 | `FROZEN_TYPES`(14) / `PHASE1_TYPES`(11),`address/user/users` 明确报"二期开放,一期不可用" |
| 白名单 **19 项** | 16 项同源 + `precision/unit/searchable`;`valueSchema` **不在**名单内(它是契约 §4 读接口生成物,设计器回传整份 schema 时必须剥掉,否则按契约拒绝) |
| `options` | 只接受两种形态:`{label,value}` 对象数组(**字符串数组被拒**,V2)或字典引用对象 `{"source":"dict:{code}"}`;条目 label ≤128、value 非空 ≤64 且同字段内唯一 |
| `searchable` | 只认真布尔值;`searchableKeys()` 是"筛选候选列"的唯一出处(V3),已开 `GET /api/form/admin/filterFields` 给 T4/T8 消费 |
| key 规则 | `^[A-Za-z0-9_]{1,64}$` + 表单内唯一;**跨版本同 key 不得换 type**(`assertFieldTypesStable`)——否则 `answer_index` 的 `v_str/v_num/v_date` 与按 type 收敛的检索算子错配,V8"删字段后历史仍可筛"当场失效 |
| 值形状(契约 §3) | `defaultValue` 按其 type 的 §3 形状校验(number 给字符串、date 非 `YYYY-MM-DD`、multiselect 非数组都拒);`precision ≤6` 对齐 `v_num DECIMAL(20,6)` |
发布路径三道校验:静态契约 → 跨版本字段身份 → 字典可解析(`FormDictService#assertRefsResolvable`)。
### 1.4 字典(契约 §2.1 三条不变式各就各位)
- **两级 + 遮蔽**:管理面按**同层精确匹配**(校级碰不到平台级行,方案 §6.5"可见可用不可改");解析面按域 `sch_id ∈ {会话校, 0}` 且**本校遮蔽平台级且不回落**(与表单 resolve 同一套校域规则)。
- **value 不可变**:`value` 是条目的对外定位键(接口签名里根本没有"改 value"的通道)+ 列 `updatable=false` + `uk(dict_id,value)` 三重;`label/sort` 可改,测试断言改 label 后 `allowedValues()` 集合逐元素不变(V2 后半句的字典侧依据)。
- **软删条目两口径**:`inlineOptions()`(未删,设计器候选)与 `allowedValues()`(**含软删**,T3 提交放行/V38)——两个方法名把口径钉死,防止后来者混用。同 value 重新编辑即回到可选集。
- **活引用不进快照**:`schema_json` 存 `{"source":"dict:grade"}`,`expandSchema()` 只在读路径展开;测试证明"字典改名后版本行一个字节没变,而 resolve 返回的 label 已经变了"。字典缺失时展开为 `[]` + warn(降级但不静默丢字段)。
### 1.5 定义域接口(21 端点,全部在 CAS 墙后)
`/api/form/admin/**`(`FormAdminGuard` 把关):`list` `get` `versions` `version` `filterFields` `create` `save` `publish` `disable` `enable` `softDelete` `copy` `config` `statusDefs` `dict/list` `dict/selectable` `dict/save` `dict/delete` `dict/item/save` `dict/item/delete`;
`/api/form/resolve?code=&appCode=`(填报入口寻址,**租户域只取会话校**、不接受外部传 schId;options 已内联展开)。
寻址纪律:表单一律按 `uuid`(管理端)或 `code`(入口)寻址,字典按 `(域, code)`、条目按 `(dictCode, value)`——`id` 不出现在接口参数与响应外层。
### 1.6 环境动作(本机副作用,供知悉)
- 复用 T1 拉起并已确认在跑的本地 MySQL 8.0.45(3306)+ 库 `bsch_formcenter`;`ddl-auto=update` 起了定义域四表(见 §2 取证)。
- 为验证 `sql/t2_definition_domain.sql` 可执行,临时建过库 `bsch_formcenter_ddlcheck_t2`,**验证完已删除**,`SHOW DATABASES LIKE 'bsch_formcenter%'` 现只剩 `bsch_formcenter`。
- `form-center/local-debug/` **不在 service/front 任一仓工作树内**(目录树里 form-center 根无 .git),故 `t2-def-smoke.sh` 与 T1 的 `t1-accept-smoke.sh` 一样是本地件、不随本轮推送;若要让脚本进版本库,需先在 form-center 根定一个归属仓(建议 service 仓 `src/test/resources/local-debug/` 或单开 repo)→ 记本节而非问题单,属编排事项。
- IT 全部 `@Transactional` 回滚,开发库现无残留业务行(`form`/`form_dict_item` COUNT=0 已核);仅 AUTO_INCREMENT 计数前移(回滚不掉自增游标,无害)。
- 未启动 CAS/UserBase/Redis,未触碰 160 共享环境。
---
## 2. 自验证据(逐 V)
**门禁命令**:`mvn -B -o clean package`(离线,settings.xml 已指本地仓库,未传 `-Dmaven.repo.local`)
→ **BUILD SUCCESS**,`Tests run: 67, Failures: 0, Errors: 0, Skipped: 0`,产物 `target/form-center.jar`。
| V | 断言(方案 §9 原文口径) | 证据 |
| --- | --- | --- |
| **V2** | `options` 非 `{label,value}` 结构被拒;改 label 不改 value 后历史筛选/分组口径不变 | `FormSchemaValidatorTest.stringArrayOptionsRejected`(四类选项控件逐个断言"字符串数组被拒")、`optionValueMustBePresentAndUnique`、`optionExtraPropRejected`;字典侧 `FormDictServiceIT.labelChangeKeepsValueSetStable`(改 label 后 `allowedValues` 集合 equals 改前、inline 序列仍 g2,g3);`FormDefServiceIT.saveRejectsInvalidSchemaWithoutTouchingDraft`(非法 options 在保存阶段被拦,草稿行原样) |
| **V3** | 白名单外属性被拒;未声明 `searchable` 的字段不进筛选候选 | `FormSchemaValidatorTest.propsOutsideWhitelistRejected`(`orderNum`/`dataValidator`/`valueSchema`/`required2`/`options2` 五种都拒)、`whitelistIsTheFrozenNineteen`(19 项清点)、`searchableMustBeBoolean`、`searchableKeysOnlyDeclaredOnes`(`containsExactly("a","c")`,未声明的 b 与显式 false 的 d 都不进);接口侧 `GET /api/form/admin/filterFields` 取当前发布版本 schema 复用同一函数 |
| **V6** | 同 `sch_id` + 同 `code`、不同 `app_code` 共存不冲突 | `FormDefServiceIT.sameCodeUnderDifferentAppCodesCoexist`(两条都建成功、uuid 不同、按 appCode 各查到 1 条、同 app_code 重复被拒);`resolveIsScopedByAppCode`(同 code 不同 app_code 各回各家);`twoSchoolsAreIndependentNamespaces`。物理侧 `SHOW CREATE TABLE form` → `UNIQUE KEY uk_form (sch_id, app_code, code)` |
| **V7** | 已发布版本无法就地修改;改版产生 `version+1` 新行;老版本 `schema_json` 逐字节不变 | `FormDefServiceIT.publishedVersionIsImmutableAndUpgradeAddsNewRow`:发布后 `version(1).schemaJson == SCHEMA_A`(常量逐字节比对)→ 保存 SCHEMA_B 得 `draftVersion=2` 且 `currentVersion` 仍 1 → 再断言 v1 仍 `== SCHEMA_A`、v2 `== SCHEMA_B` → 发布后 v1 依旧逐字节不变、历史 `[2,1]` 降序;`republishWithoutDraftIsRejected`(无草稿时再发布被拒,不给就地改留入口);`fieldIdentityIsStableAcrossVersions`(同 key 换 type 拒) |
| **V12** | 单表单可自定义状态集合,默认含"待处理/已处理" | `FormStatusDefsTest.defaultSetIsPendingAndProcessed`(列值 null/空白 → 待处理/已处理)、`customSetReplacesDefault`(自定义后默认不再存在、顺序即展示序、存储形状仍是 `[{code,label,is_deleted}]`)、`deletedDefStillResolvable`(V13 的存储前提)、`reuseOfDeletedCodeRejected`、`allDeletedRejected`;IT 侧 `FormDefServiceIT.formLevelConfigDoesNotCreateVersion` 断言状态定义/配置改动**不产生新版本行**且 v1 schema 不变 |
| **V41**(编排补充) | 副本=新 code/uuid/version 1、schema 深拷贝、字典引用随行;原表单与其历史数据零改动 | `FormCopyIT.copyCreatesFreshIdentityAndKeepsSourceUntouched`:新 uuid ≠ 源 uuid、`code=honor_apply_2027`、`draftVersion=1`、`currentVersion=null`、`status=DRAFT`、副本 v1 未被发布;副本 schema 与源 v1 **等值且仍含 `dict:grade`**(活引用随行);源表单逐字段回比 + v1 schema 逐字节比 + 版本历史不变;末尾断言"字典改 label 后源与副本读接口同时生效"(共享活引用,不是各自快照)。另有 `copyInheritsConfigButNotDeadline`、`copyCanTargetAnotherAppCodeAndRejectsDuplicateCode`、`copyInputGuards` 三条边界 |
**其它取证**
| 项 | 结果 |
| --- | --- |
| 契约 §1/§10 校域与标识 | `FormDefServiceIT.uuidGeneratedAtDraftAndIdsNotExposed`(建草稿即生成 32 位 `[0-9a-f]`、升版跨版本不变)、`resolveAppliesSchoolShadowing`(草稿期不穿透到平台级、遮蔽不回落、停用给"已停用"业务错)、`codeEditableOnlyBeforePublish`、`softDeletedFormKeepsItsCode`、`responseDtosDoNotExposeRowIds`(`objectMapper.writeValueAsString(FormView/VersionView)` 不含 `"id"`) |
| 契约 §5.4 生命周期 | `disableAndEnableRestoreCurrentVersion`(停用只断入口、版本历史仍可查;恢复回当前版本且**不新增版本行**) |
| 字典语义 | `FormDictServiceIT` 8 条:平台级可见可用不可改 / 校级遮蔽平台级 / value 稳定 / 软删条目放行 / 内联展开与降级空数组 / 发布前可解析性(未知字典、空字典都拦) / 活引用不进快照 / 字典 code 规则与软删恢复 |
| MySQL 5.7 复核(P10) | ①T2 **零 native SQL**(全 JPQL/派生查询,无 CTE、无函数索引);②`sql/t2_definition_domain.sql` 在本地实跑 `mysql_exit=0` 建出 4 表(scratch 库已删);③`SHOW CREATE TABLE` 逐列比对契约 §10 + P5 两列,utf8mb4/InnoDB/无物理 FK/TEXT 无 DEFAULT 全部成立;④复合唯一键 520~524 字节 < 767,无需前缀长度 |
| 启动冒烟 | `bash local-debug/t2-def-smoke.sh` → **5/5 PASS**:health=200;`/api/form/admin/list`、`/api/form/admin/dict/list`、`/api/form/resolve?code=…` 无票均 **302→CAS**(证明新映射已装载、且在鉴权墙后);`/oapi/*` 仍 401 |
| 登录态冒烟(P1 遗留项) | 本轮**未取得会话身份**(本地 CAS/UserBase 未启),接口以"墙后 302 + 服务层 27 条直连真实 MySQL 的 IT"替代取证;缺口不变,按 P1 归 T5 联调轮补跑 |
---
## 3. 问题单(本轮登记,供执行手册 §5 裁决;编号接续 P13)
| # | 问题 | 本轮取值(最保守解读) | 影响面 |
| --- | --- | --- | --- |
| P14 | 契约 §6 错误码表要 **PARAM/VALIDATE/AUTH/NOT_FOUND/业务规则** 五段,但 `javap` 实测平台 `ErrorCode` 只有 8 值:SUCCESS 0 / ERROR 1 / PARAM_ERROR 2 / FORBIDDEN 3 / UNAUTHORIZED 4 / CUSTOM 5 / INTERNAL_SERVER_ERROR 6 / HTTP_METHOD_NOT_ALLOWED 405——**没有 VALIDATE 与 NOT_FOUND 段** | 按现有枚举映射并全线沿用:参数·白名单·值形状=PARAM_ERROR(2)、未登录=UNAUTHORIZED(4)、越权/无管理权=FORBIDDEN(3)、业务规则(未发布/已停用/无草稿可发布/已发布行不可改,T3 的限次与截止同段)=**CUSTOM(5)**、"code/uuid 未找到"=PARAM_ERROR(2)(flow-center 先例)。契约 §6 自称"建议稿,对齐后定稿",请裁决:改契约口径为四段,或平台侧加枚举(跨项目) | 全后端信封语义;T3/T4 沿用,前端 T5~T8 按 msg 展示 |
| P15 | 契约 §10 写 `create_time DATETIME NOT NULL`,而 `BaseEntity`(T1 已验收、P5 裁决保留)两列未标 `nullable=false` → `ddl-auto=update` 生成 `DEFAULT NULL` | 保留基类现状(改基类影响全平台所有应用,不在 T2 范围);业务上恒非空(`FormCenterAuditListener` 每次 persist/update 都写 `now()`)。`sql/t2_definition_domain.sql` 按**建表实态**写 `DEFAULT NULL` 并注明本条 | 仅 DDL 约束文案;不改 §10 文本(P5 口径:草案非权威 schema) |
| P16 | `code` 的字符集契约只写"人可读串 ≤64",而它要进 `/form-center/f/{code}` 与配置引用 | 取 URL-safe 保守子集 `^[A-Za-z0-9_-]{1,64}$`(含连字符,不含中文/空格/点/百分号) | T6 设计器需给出同规则的前端校验;T8 快应用模块行 path 需对齐 |
| P17 | 分组结构:契约 §2 示例是 `{"groups":[{"key","label","fields"}]}`,但"key 唯一/发布后不可改"只写了字段级 | 要求 group 必带 `key`(同 KEY_PATTERN)+ `label` 必填,且 **group.key 与 field.key 共用一个唯一命名空间**(读路径按 key 寻址时避免歧义);group 属性白名单锁 `key/label/fields` | 与 svc-hall 旧形态(`title`、可无 key)不同,T6 需按此产出 |
| P18 | `options[].value` 与字典条目 `value` 的形态契约未写(只说"生成一次即不可变") | 只定三条硬边界:非空、**≤64**(对齐 `form_dict_item.value varchar(64)`,避免 inline 与字典两种长度语义)、同字段/同字典内唯一;不额外限制字符集。字典 `code` 因要在 `dict:{code}` 里解析,取 KEY_PATTERN | 若超管要往平台级字典塞中文 value,本规则允许(中文 ≤64 字符即可) |
| P19 | 契约 §2"options value 不可变"在服务端**不可校验**:升版时"改了某个 value"与"删一项加一项"无法区分,而删/加都是合法升版(V8/V9 路径) | 实现可校验的两条替代:①字典条目 value=物理不可变(`updatable=false` + 接口无改写通道 + `uk`);②同 key 跨版本不得换 type。另一半(inline options 的 value 不被改动)由 **T6 设计器纪律**保证:输入 label 自动生成 value、改 label 不动 value | V2 的"改 label 历史不变"在服务端只能覆盖字典侧与 type 稳定性 |
| P20 | 已发布表单"升版过程中"是否仍可填报,方案只写"改定义走升版""重新发布可恢复当前版本",未写窗口期语义 | 取**旧版仍可填**:保存只建草稿行,头表 `status/current_version` 不动,发布才推指针(与契约 §5 钉版、§3.2 两层缓存声明自洽)。副作用:设计者可能以为"保存了就生效"——T6 需在 UI 上区分"已发布版本 / 待发布草稿" | T6 设计器文案;T7 resolve 语义无影响 |
| P21 | 定义管理权的载体:方案 §6.1 第②层是"模块行授权(表单设计/字典管理)",但那是 T4 的 GetUserMod 授权树扫描链路;本轮接口必须先有闸门 | 沿用 fork 模板 flow-center 的角色口径:超管(`type==1`,读 UserBase `UserTypeEnum` 源码确认:0 普通/1 SUPER_ADMIN/2 校级管理员——**注意 `BoaUser.type` 的 javadoc 写成"1=教师 2=家长"已过时**)可跨校且唯一可管 `sch_id=0` 平台级行;校级管理员 `roleId∈{2,5,7}` 限本校。`FormAdminGuard` javadoc 已写明该缺口归属 T4/T8。附带请裁决:**局领导(5) 按模板口径拿到定义编辑权**是否收紧 | 权限把关链路;T4 实现 scope 扫描时应把本守卫切到"授权模块集"口径 |
| P22 | 软删表单仍占用 `uk_form(sch_id, app_code, code)`(契约 §10 唯一键不含 `is_deleted`)→ 删掉一张表单后无法用同 code 重建 | 不自动改名、不物理删("有答卷的表单禁物理删除"是硬约束),给点名提示"同 code 的表单已存在(软删行仍占用该 code),请另起 code"。若要"删后可复用 code",需契约侧把 `is_deleted` 纳入唯一键——这会改 V6 的键语义,请裁决 | 运营体验;快应用模块行按 code 寻址,复用已删 code 本身有风险(本轮倾向保持现状) |
---
## 4. 推送与 CI
| 仓 | 提交 | 推送 | CI run | 结论 |
| --- | --- | --- | --- | --- |
| form-center-service | 待回填 | 待回填 | 待回填 | 待回填 |
> 纪律:常规 `git push origin main`(T1 已 clone 建仓,无需 `--allow-unrelated-histories`);`git add` 只按显式路径逐个添加,未用 `add -A`。
> front 仓本轮无改动(T2 是纯后端任务),不产生推送。
—— 全文完 ——
+91
View File
@@ -0,0 +1,91 @@
-- ============================================================
-- 表单中心 T2 · 定义域 DDL(MySQL 5.7 兼容复核版)
-- 范围:契约 §10 九表中的定义域四表 = form / form_version / form_dict / form_dict_item
-- (answer / answer_index / status_log / answer_op_log / fill_quota 属 T3)
-- 用途:本文件是**评审与生产脚本底稿**,不是应用启动依赖。
-- 开发期由 spring.jpa.hibernate.ddl-auto=update 自动建表(application.yml);
-- 生产建议 ddl-auto=none + 按本脚本升级(P10:160 生产 MySQL 版本待核,按 5.7 保守写)。
-- 5.7 复核要点:①无 CTE/WITH;②无函数索引/生成列;③utf8mb4(5.7 默认 utf8mb3 会截断 emoji/生僻字);
-- ④复合唯一键字节长 (8+64*4+64*4)=520 < 767,两个 uk 均不需要前缀长度;
-- ⑤TEXT/LONGTEXT 列不写 DEFAULT(5.7 不允许);⑥无物理外键(方案 §7.7 逻辑外键约定)。
-- P5 裁决:实体继承 BaseEntity,实际表=契约 §10 草案 + create_by/update_by 两列(不改 §10 文本)。
-- 审计两列在 BaseEntity 未标 nullable=false,故本脚本按建表实态写 DEFAULT NULL
-- (FormCenterAuditListener 恒写 now(),业务上不会为空)——差异记问题单 P15。
-- 本机取证:MySQL 8.0.45 / 库 bsch_formcenter 由 ddl-auto=update 建表后 SHOW CREATE TABLE 逐列比对一致。
-- ============================================================
CREATE TABLE `form` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`uuid` VARCHAR(32) NOT NULL COMMENT '数据面运行时身份:建草稿即生成、跨版本不变(契约 §1)',
`sch_id` BIGINT NOT NULL COMMENT '租户键;0=平台级表单(U5/U6,超管上下文建)',
`app_code` VARCHAR(64) NOT NULL DEFAULT 'form-center' COMMENT '归属应用:中心/平台级挂 form-center,快应用挂其 app_code',
`code` VARCHAR(64) NOT NULL COMMENT '入口寻址+配置期引用;同校同 app_code 内唯一;发布后不可改',
`name` VARCHAR(128) NOT NULL,
`current_version` INT DEFAULT NULL COMMENT '当前发布版本指针,NULL=从未发布(resolve 只认它)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 草稿 / 1 已发布 / 2 停用(停用只断填报入口,数据可读可导)',
`status_defs` TEXT COMMENT '状态定义 JSON [{code,label,is_deleted}];NULL=默认待处理/已处理(V12)',
`audience` TINYINT NOT NULL DEFAULT 1 COMMENT '1 面向学生 / 2 面向教师(数据范围默认矩阵输入)',
`fill_limit` TINYINT NOT NULL DEFAULT 0 COMMENT '0 不限 / 1 限一次(去重维度按 subject→submitter 推导)',
`deadline` DATETIME DEFAULT NULL COMMENT '收集截止,datetime 粒度(只选日期时补当日 23:59:59)',
`answer_no_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '报名号开关,默认关',
`group_tag` VARCHAR(64) DEFAULT NULL COMMENT '多表单快应用的菜单分组标签',
`is_deleted` TINYINT NOT NULL DEFAULT 0 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_form_uuid` (`uuid`),
UNIQUE KEY `uk_form` (`sch_id`, `app_code`, `code`),
KEY `idx_form_app` (`sch_id`, `app_code`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='表单头表(方案 §5.1)';
CREATE TABLE `form_version` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`form_id` BIGINT NOT NULL COMMENT '逻辑外键 → form.id(无物理 FK)',
`sch_id` BIGINT NOT NULL,
`app_code` VARCHAR(64) NOT NULL,
`code` VARCHAR(64) NOT NULL,
`version` INT NOT NULL COMMENT '只增不改(契约 §1)',
`schema_json` LONGTEXT NOT NULL COMMENT '结构快照:{"groups":[{key,label,fields:[…]}]},数组序即展示序、无 orderNum',
`change_log` VARCHAR(500) DEFAULT NULL COMMENT '升版改动说明',
`publish_time` DATETIME DEFAULT NULL COMMENT 'NULL=未发布草稿行;发布动作只补这一列,内容列从此冻结(V7)',
`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_ver` (`sch_id`, `app_code`, `code`, `version`),
KEY `idx_ver_form` (`form_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='版本行:行不可变(方案 §3.2)——已发布行禁止就地修改,改版=新增行';
CREATE TABLE `form_dict` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`sch_id` BIGINT NOT NULL DEFAULT 0 COMMENT '0=平台级(学校可见可用不可改);其余=校级(仅本校表单可引用)',
`code` VARCHAR(64) NOT NULL COMMENT 'schema 里 options.source="dict:{code}" 的引用键,建后不可改',
`name` VARCHAR(128) NOT NULL,
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`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_dict` (`sch_id`, `code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='选项字典(契约 §2.1)';
CREATE TABLE `form_dict_item` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`dict_id` BIGINT NOT NULL COMMENT '逻辑外键 → form_dict.id',
`value` VARCHAR(64) NOT NULL COMMENT '答案存储值:建一次即不可变(实体 updatable=false + 本 uk)',
`label` VARCHAR(128) NOT NULL COMMENT '展示名,可随便改(改名不打穿历史答卷)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '展示顺序(同值按 id 稳定排)',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删:设计器不可新选,但 value 仍通过提交校验(V38)',
`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_dict_item` (`dict_id`, `value`),
KEY `idx_dict_item_sort` (`dict_id`, `sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='字典条目(契约 §2.1)';
@@ -0,0 +1,86 @@
package com.yunhai.formcenter.common;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* 表单级填报约束的纯规则(方案 §5.4 / 契约 §5)。
*
* <p>放在 common 而不是 service:提交接口(T3)、只读态判定(T7 前端要的字段)、
* 限次登记(fill_quota 的 limit_key)都要同一份口径,写进 service 就会被复制两遍。</p>
*/
public final class FormRuntimeRules {
/** 管理员只选日期时的当日收尾时刻(方案 §5.4:"datetime 粒度,只选日期默认当日 23:59:59") */
public static final java.time.LocalTime DEADLINE_TIME_OF_DAY = java.time.LocalTime.of(23, 59, 59);
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private FormRuntimeRules() {
}
/**
* 截止时间解析:{@code yyyy-MM-dd} → 当日 23:59:59;{@code yyyy-MM-dd HH:mm:ss} → 原样;
* 空串/null → null(不限)。其它形态直接拒绝(不让"2026/10/1"这种悄悄进库变成解析歧义)。
*/
public static LocalDateTime parseDeadline(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return null;
}
String value = raw.trim();
try {
if (value.length() <= 10) {
return LocalDate.parse(value, DATE).atTime(DEADLINE_TIME_OF_DAY);
}
return LocalDateTime.parse(value, DATE_TIME);
} catch (DateTimeParseException e) {
throw new BizException(ErrorCode.PARAM_ERROR,
"截止时间格式应为 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss:" + value);
}
}
/** 是否已过截止(严格"晚于"才过期:23:59:59 当秒仍可提交,契约 §5 datetime 粒度) */
public static boolean isDeadlineReached(Form form, LocalDateTime now) {
if (form == null || form.getDeadline() == null) {
return false;
}
return (now == null ? LocalDateTime.now() : now).isAfter(form.getDeadline());
}
/**
* 限一次的去重维度(方案 §5.4 v1.2 定死):{@code subject_id} 非空按 subject 限,否则按 submitter 限。
* 代填时按 submitter 限会出"一人两份",而"一人一份"(家访/体检/学籍)要的恰恰是按学生限。
*/
public static FormEnums.LimitScope limitScope(Form form, Integer subjectId) {
if (form == null || !FormEnums.FillLimit.once(form.getFillLimit())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该表单未开启限填一次");
}
return subjectId != null ? FormEnums.LimitScope.SUBJECT : FormEnums.LimitScope.SUBMITTER;
}
/**
* fill_quota.limit_key(契约 §10:"coalesce(subject_id, submitter_id) 字符串化")。
* 与幂等键是两个机制:幂等防双击重复,限次防多次提交。
*/
public static String quotaKey(Integer subjectId, Integer submitterId) {
Integer key = subjectId != null ? subjectId : submitterId;
if (key == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "限次键需要 subject_id 或 submitter_id");
}
return String.valueOf(key);
}
/** 生命周期是否可填报(方案 §5.4:停用=填报入口失效;草稿=未开放入口) */
public static boolean isFillOpen(Form form) {
return form != null && form.getStatus() != null
&& form.getStatus() == FormEnums.FormStatus.PUBLISHED.getCode();
}
}
@@ -0,0 +1,466 @@
package com.yunhai.formcenter.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 表单 schema 契约校验器(契约 §2/§2.1,方案 §4.2/§4.3①②③⑤)。
*
* <p>与 svc-hall {@code FormValidator} 同源处:14 类控件命名一次冻结、一期放行 11 类、
* {@code ^[A-Za-z0-9_]{1,64}$} 的 key 规则、属性白名单外一律拒绝(防"存库 JS/eval"类契约外语义渗入)。
* 关键差异(新契约的三处硬修订):</p>
* <ol>
* <li><b>options 必须是 {@code {label,value}} 对象数组,字符串数组被拒</b>(契约 §2 / V2)——
* svc-hall 的 label 即 value,老师把"高二年级"改成"高二"立刻打穿历史统计;</li>
* <li>options 可为<b>字典引用</b>对象 {@code {"source":"dict:{code}"}}(契约 §2.1);</li>
* <li>白名单 16 项 → <b>19 项</b>,新增 {@code precision / unit / searchable}(契约 §2)。</li>
* </ol>
*
* <p>本类只做<b>纯静态</b>校验(不碰库):字典引用是否可解析属于跨表事实,在
* {@code FormDictService#assertRefsResolvable} 发布路径上查。提交值校验(按钉住版本)属 T3。</p>
*/
public final class FormSchemaValidator {
/** 14 类控件命名一次冻结(契约 §2、方案 §4.2) */
public static final Set<String> FROZEN_TYPES = new LinkedHashSet<>(Arrays.asList(
"text", "textarea", "number", "date", "select", "radio", "upload",
"checkbox", "multiselect", "phone", "address", "image", "user", "users"));
/** 一期放行 11 类(address/user/users 二期,契约 §2) */
public static final Set<String> PHASE1_TYPES = new LinkedHashSet<>(Arrays.asList(
"text", "textarea", "number", "date", "select", "radio", "upload",
"checkbox", "multiselect", "phone", "image"));
/** 需要 options 的四类选项控件(inline 或 dict 引用二选一,且必须非空) */
public static final Set<String> OPTION_TYPES = new LinkedHashSet<>(Arrays.asList(
"select", "radio", "checkbox", "multiselect"));
/**
* 字段属性白名单 19 项(契约 §2:白名单外一律拒绝)。
* 前 16 项与 svc-hall 冻结骨架同源,后 3 项为 v1.1 新增。
* 注意:{@code valueSchema} 不在名单内——它是读接口生成的机读值形状(契约 §4),
* 设计器回传整份 schema 时必须剥掉,否则按契约拒绝。
*/
public static final Set<String> ALLOWED_PROPS = new LinkedHashSet<>(Arrays.asList(
"key", "label", "type", "required", "pattern", "min", "max", "maxLength", "tip",
"placeholder", "options", "rows", "defaultValue", "accept", "maxSize", "maxFiles",
"precision", "unit", "searchable"));
/** 分组结构白名单(契约 §2 示例:{@code {key,label,fields}},数组序即展示序、无 orderNum) */
private static final Set<String> ALLOWED_GROUP_PROPS = new LinkedHashSet<>(Arrays.asList(
"key", "label", "fields"));
/** 选项对象白名单(契约 §2:{@code {label,value}}) */
private static final Set<String> ALLOWED_OPTION_PROPS = new LinkedHashSet<>(Arrays.asList(
"label", "value"));
/** 字段 key:表单内唯一,发布后不可改(改 key=换字段,契约 §2) */
public static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,64}$");
/** 字典引用形态:{@code options.source = "dict:{code}"},code 同 key 字符集 */
public static final Pattern DICT_SOURCE_PATTERN = Pattern.compile("^dict:[A-Za-z0-9_]{1,64}$");
/** 选项 value 上限:与 form_dict_item.value varchar(64) 同口径(契约 §10),也是 uk(dict_id,value) 的列宽 */
public static final int MAX_OPTION_VALUE_LEN = 64;
/** 展示名上限:与 form_dict_item.label varchar(128) 同口径 */
public static final int MAX_LABEL_LEN = 128;
/** date 存储格式定死 YYYY-MM-DD(契约 §3、方案 §4.3④) */
private static final Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
private static final ObjectMapper MAPPER = new ObjectMapper();
private FormSchemaValidator() {
}
// ==================== 发布期静态校验 ====================
/**
* 校验 schema_json,非法即抛 {@link BizException}(PARAM 类,msg 指明字段与原因,契约 §6)。
*
* @return 字段总数(信息用)
*/
public static int validateSchema(String schemaJson) {
JsonNode root = parse(schemaJson, "表单结构不是合法 JSON");
if (!root.isObject() || !root.path("groups").isArray()) {
throw fail(null, "表单结构必须是 {\"groups\":[…]}");
}
JsonNode groups = root.get("groups");
if (groups.size() == 0) {
throw fail(null, "表单至少要有一个分组");
}
Set<String> keys = new LinkedHashSet<>();
int total = 0;
for (JsonNode group : groups) {
if (!group.isObject()) {
throw fail(null, "分组必须是对象");
}
rejectUnknownProps(group, ALLOWED_GROUP_PROPS, "分组属性");
String groupKey = text(group, "key");
if (groupKey == null || !KEY_PATTERN.matcher(groupKey).matches()) {
throw fail(null, "分组 key 仅限字母/数字/下划线(≤64 位):" + groupKey);
}
if (!keys.add(groupKey)) {
throw fail(null, "分组 key 与字段 key 冲突:" + groupKey);
}
String groupLabel = text(group, "label");
if (groupLabel == null || groupLabel.trim().isEmpty()) {
throw fail(null, "分组缺少 label:" + groupKey);
}
if (!group.path("fields").isArray()) {
throw fail(null, "每个分组必须含 fields 数组:" + groupKey);
}
for (JsonNode field : group.get("fields")) {
validateField(field, keys);
total++;
}
}
if (total == 0) {
throw fail(null, "表单至少要有一个字段");
}
return total;
}
private static void validateField(JsonNode field, Set<String> keys) {
if (!field.isObject()) {
throw fail(null, "字段必须是对象");
}
String key = text(field, "key");
rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
if (key == null || !KEY_PATTERN.matcher(key).matches()) {
throw fail(key, "字段 key 仅限字母/数字/下划线(≤64 位):" + key);
}
if (!keys.add(key)) {
throw fail(key, "字段 key 重复:" + key);
}
String label = text(field, "label");
if (label == null || label.trim().isEmpty()) {
throw fail(key, "字段缺少 label");
}
if (label.length() > MAX_LABEL_LEN) {
throw fail(key, "label 过长(≤" + MAX_LABEL_LEN + ")");
}
String type = text(field, "type");
if (type == null || !FROZEN_TYPES.contains(type)) {
throw fail(key, "字段类型未冻结:" + type);
}
if (!PHASE1_TYPES.contains(type)) {
throw fail(key, "该类型二期开放,一期不可用:" + type);
}
validateBoolean(field, key, "required");
validateBoolean(field, key, "searchable");
validatePattern(field, key);
validateNumberRange(field, key);
validateCountProps(field, key);
validateTextProps(field, key);
validatePrecisionAndUnit(field, key);
validateOptions(field, key, type);
validateDefaultValue(field, key, type);
}
/** options 契约(契约 §2):{@code {label,value}} 对象数组,或字典引用对象,二者之外一律拒绝 */
private static void validateOptions(JsonNode field, String key, String type) {
JsonNode options = field.get("options");
if (options == null || options.isNull()) {
if (OPTION_TYPES.contains(type)) {
throw fail(key, "select/radio/checkbox/multiselect 必须提供 options");
}
return;
}
if (options.isObject()) {
rejectUnknownProps(options, new HashSet<>(Arrays.asList("source")), "options(" + key + ")");
String source = text(options, "source");
if (source == null || !DICT_SOURCE_PATTERN.matcher(source).matches()) {
throw fail(key, "字典引用形态必须是 \"source\":\"dict:{code}\"(code 限字母/数字/下划线):" + source);
}
return;
}
if (!options.isArray()) {
throw fail(key, "options 必须是 {label,value} 对象数组或字典引用对象");
}
// 字符串数组=svc-hall 的旧形态,label 即 value,改名即打穿历史统计(V2 的拒绝项)
if (OPTION_TYPES.contains(type) && options.size() == 0) {
throw fail(key, "options 不能为空数组");
}
Set<String> values = new LinkedHashSet<>();
for (JsonNode option : options) {
if (!option.isObject()) {
throw fail(key, "options 必须是 {label,value} 对象数组,字符串数组被拒(当前值:"
+ option.asText() + ")");
}
rejectUnknownProps(option, ALLOWED_OPTION_PROPS, "options 条目(" + key + ")");
String value = text(option, "value");
String optionLabel = text(option, "label");
if (optionLabel == null || optionLabel.trim().isEmpty()) {
throw fail(key, "options 条目缺少 label");
}
if (optionLabel.length() > MAX_LABEL_LEN) {
throw fail(key, "options 条目 label 过长(≤" + MAX_LABEL_LEN + ")");
}
if (value == null || value.trim().isEmpty()) {
throw fail(key, "options 条目缺少 value(答案存 value,label 可随便改)");
}
if (value.length() > MAX_OPTION_VALUE_LEN) {
throw fail(key, "options 条目 value 过长(≤" + MAX_OPTION_VALUE_LEN + "):" + value);
}
if (!values.add(value)) {
throw fail(key, "options 条目 value 重复:" + value);
}
}
}
/** defaultValue 的 JSON 类型须与契约 §3 该控件的值形状一致(只校形状,不校是否落在 options 内) */
private static void validateDefaultValue(JsonNode field, String key, String type) {
JsonNode def = field.get("defaultValue");
if (def == null || def.isNull()) {
return;
}
switch (type) {
case "number":
if (!def.isNumber()) {
throw fail(key, "defaultValue 对 number 必须是数字(空值请留空而非 \"\")");
}
break;
case "date":
if (!def.isTextual() || !DATE_PATTERN.matcher(def.asText()).matches()) {
throw fail(key, "defaultValue 对 date 必须是 \"YYYY-MM-DD\"");
}
break;
case "checkbox":
case "multiselect":
case "upload":
case "image":
if (!def.isArray()) {
throw fail(key, "defaultValue 对 " + type + " 必须是数组");
}
if (("checkbox".equals(type) || "multiselect".equals(type)) && !allTextual(def)) {
throw fail(key, "defaultValue 对 " + type + " 必须是字符串数组(存 value 数组)");
}
break;
default:
if (!def.isTextual()) {
throw fail(key, "defaultValue 对 " + type + " 必须是字符串");
}
break;
}
}
private static void validatePrecisionAndUnit(JsonNode field, String key) {
JsonNode precision = field.get("precision");
if (precision != null && !precision.isNull()) {
if (!precision.isIntegralNumber() || precision.intValue() < 0) {
throw fail(key, "precision 必须是非负整数");
}
// 上界取 6:answer_index.v_num 是 DECIMAL(20,6)(契约 §10),超出即索引侧丢精度
if (precision.intValue() > 6) {
throw fail(key, "precision 不得大于 6(索引列 v_num DECIMAL(20,6))");
}
}
JsonNode unit = field.get("unit");
if (unit != null && !unit.isNull()) {
if (!unit.isTextual() || unit.asText().trim().isEmpty()) {
throw fail(key, "unit 必须是非空字符串");
}
}
}
private static void validateBoolean(JsonNode field, String key, String prop) {
JsonNode node = field.get(prop);
if (node != null && !node.isNull() && !node.isBoolean()) {
throw fail(key, prop + " 必须是 true/false");
}
}
private static void validatePattern(JsonNode field, String key) {
String pattern = text(field, "pattern");
if (pattern == null) {
return;
}
try {
Pattern.compile(pattern);
} catch (Exception e) {
throw fail(key, "pattern 不可编译:" + pattern);
}
}
private static void validateNumberRange(JsonNode field, String key) {
JsonNode min = field.get("min");
JsonNode max = field.get("max");
if (min != null && !min.isNull() && !min.isNumber()) {
throw fail(key, "min 必须是数字");
}
if (max != null && !max.isNull() && !max.isNumber()) {
throw fail(key, "max 必须是数字");
}
if (min != null && min.isNumber() && max != null && max.isNumber()
&& min.asDouble() > max.asDouble()) {
throw fail(key, "min 不得大于 max");
}
}
/** 计数类属性:必须是整数且非负(maxLength/rows 从 1 起) */
private static void validateCountProps(JsonNode field, String key) {
requireIntAtLeast(field, key, "maxLength", 1);
requireIntAtLeast(field, key, "rows", 1);
requireIntAtLeast(field, key, "maxSize", 0);
requireIntAtLeast(field, key, "maxFiles", 0);
}
private static void validateTextProps(JsonNode field, String key) {
for (String prop : Arrays.asList("tip", "placeholder", "accept")) {
JsonNode node = field.get(prop);
if (node != null && !node.isNull() && !node.isTextual()) {
throw fail(key, prop + " 必须是字符串");
}
}
}
private static void requireIntAtLeast(JsonNode field, String key, String prop, int min) {
JsonNode node = field.get(prop);
if (node == null || node.isNull()) {
return;
}
if (!node.isIntegralNumber() || node.intValue() < min) {
throw fail(key, prop + " 必须是 ≥" + min + " 的整数");
}
}
// ==================== 跨版本稳定性(契约 §2 key 发布后不可改) ====================
/**
* 发布新版本时的字段身份检查:同一 key 在不同版本里必须是同一类控件。
*
* <p>依据:契约 §2"改 key = 换字段(视同删旧增新)"+ answer_index 按 type 收敛检索算子
* (等值/区间/前缀,契约 §2)、{@code v_str/v_num/v_date} 三列分类型落值(契约 §10)。
* 若允许同 key 换 type,历史索引行与新语义错配,V8"删字段后历史仍可筛"当场失效。
* 删字段与新增字段都不受本检查约束(V8/V9 的正常路径);label/options/required/searchable 可改。</p>
*/
public static void assertFieldTypesStable(String oldSchemaJson, String newSchemaJson) {
Map<String, JsonNode> oldFields = fieldsByKeys(oldSchemaJson);
Map<String, JsonNode> newFields = fieldsByKeys(newSchemaJson);
for (Map.Entry<String, JsonNode> en : newFields.entrySet()) {
JsonNode old = oldFields.get(en.getKey());
if (old == null) {
continue;
}
String oldType = text(old, "type");
String newType = text(en.getValue(), "type");
if (oldType != null && !oldType.equals(newType)) {
throw fail(en.getKey(), "字段 key 已发布过,type 不可变更(" + oldType + "→" + newType
+ ");要换语义请另起 key(改 key=换字段,契约 §2)");
}
}
}
// ==================== 读路径共用解析(searchable 候选 / 字典引用清单) ====================
/**
* 已声明 searchable 的字段 key(V3 的另一半:未声明的字段不进筛选候选,
* 也是 T3 写 answer_index、T4 出筛选列的唯一依据)。
*/
public static List<String> searchableKeys(String schemaJson) {
List<String> result = new ArrayList<>();
for (Map.Entry<String, JsonNode> en : fieldsByKeys(schemaJson).entrySet()) {
JsonNode node = en.getValue().get("searchable");
if (node != null && node.isBoolean() && node.booleanValue()) {
result.add(en.getKey());
}
}
return result;
}
/** schema 里引用的字典 code({@code dict:{code}} 去掉了前缀),发布前据此查可解析性 */
public static List<String> dictRefCodes(String schemaJson) {
List<String> codes = new ArrayList<>();
for (JsonNode field : fieldsByKeys(schemaJson).values()) {
String code = dictRefCode(field);
if (code != null && !codes.contains(code)) {
codes.add(code);
}
}
return codes;
}
/** 该字段的字典引用 code,非字典引用返回 null */
public static String dictRefCode(JsonNode field) {
JsonNode options = field == null ? null : field.get("options");
if (options == null || !options.isObject()) {
return null;
}
String source = text(options, "source");
return source == null || !DICT_SOURCE_PATTERN.matcher(source).matches()
? null : source.substring("dict:".length());
}
/** key → 字段节点(分组顺序 + 组内数组序=展示序,契约 §2 无 orderNum) */
public static Map<String, JsonNode> fieldsByKeys(String schemaJson) {
JsonNode root = parse(schemaJson, "表单结构损坏(请联系管理员)");
Map<String, JsonNode> map = new LinkedHashMap<>();
for (JsonNode group : root.path("groups")) {
for (JsonNode field : group.path("fields")) {
String key = text(field, "key");
if (key != null) {
map.put(key, field);
}
}
}
return map;
}
public static JsonNode parse(String schemaJson, String message) {
try {
JsonNode node = MAPPER.readTree(schemaJson == null || schemaJson.trim().isEmpty() ? "{}" : schemaJson);
return node == null ? MAPPER.createObjectNode() : node;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, message);
}
}
/** 供服务层复用同一份 ObjectMapper 语义(不暴露实例,避免各处 new) */
public static ObjectMapper mapper() {
return MAPPER;
}
private static void rejectUnknownProps(JsonNode node, Set<String> allowed, String what) {
Iterator<String> names = node.fieldNames();
while (names.hasNext()) {
String prop = names.next();
if (!allowed.contains(prop)) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " 不在契约白名单内:" + prop);
}
}
}
private static boolean allTextual(JsonNode array) {
for (JsonNode n : array) {
if (!n.isTextual()) {
return false;
}
}
return true;
}
private static BizException fail(String key, String reason) {
return new BizException(ErrorCode.PARAM_ERROR,
key == null ? reason : "字段「" + key + "」" + reason);
}
private static String text(JsonNode node, String prop) {
JsonNode v = node.get(prop);
return v == null || v.isNull() ? null : v.asText();
}
}
@@ -0,0 +1,216 @@
package com.yunhai.formcenter.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
/**
* 表单级状态定义(方案 §5.3 / V12/V13,契约 §10 {@code form.status_defs})。
*
* <p>红线复述(方案 §5.3):状态是"<b>标记</b>"不是"流转"——不校验谁能从 A 到 B、无条件分支、
* 无会签、无超时;需要流转去流程中心。所以这里只有"定义集合"的增删改,没有任何转移规则。</p>
*
* <ul>
* <li>存储形态:{@code [{code,label,is_deleted}]}(契约 §10 注释定的 JSON 形状,字符串 code + 软删定义);</li>
* <li>列值为 null 时用默认集合 <b>待处理/已处理</b>(V12"默认含"的落点,不靠建表单时写入,
* 这样"从未配过状态"与"配了又清空"是两种可区分的事实);</li>
* <li>删状态=软删定义({@code is_deleted=true}),定义行不物理删——历史答卷要回显
* "已废弃:xxx"(V13,读路径消费)。</li>
* </ul>
*/
public final class FormStatusDefs {
/** 默认状态集合(方案 §5.3:默认"待处理/已处理") */
public static final String DEFAULT_DEFS_JSON =
"[{\"code\":\"pending\",\"label\":\"待处理\",\"is_deleted\":false},"
+ "{\"code\":\"processed\",\"label\":\"已处理\",\"is_deleted\":false}]";
/** code 上限:与 answer.status varchar(64) 同口径(契约 §10) */
private static final int MAX_CODE_LEN = 64;
private static final int MAX_LABEL_LEN = 128;
private static final Set<String> ALLOWED_PROPS = new HashSet<>(Arrays.asList("code", "label", "is_deleted"));
private static final ObjectMapper MAPPER = FormSchemaValidator.mapper();
private FormStatusDefs() {
}
/** 一条状态定义 */
public static final class StatusDef {
private final String code;
private final String label;
private final boolean deleted;
public StatusDef(String code, String label, boolean deleted) {
this.code = code;
this.label = label;
this.deleted = deleted;
}
public String getCode() {
return code;
}
public String getLabel() {
return label;
}
/** true=已废弃(历史答卷仍显示其名,新答卷不再给) */
public boolean isDeleted() {
return deleted;
}
}
/**
* 校验并归一化状态定义 JSON(顺序即展示顺序)。
* 抛 PARAM 类 BizException 的场景:非数组/空、属性越白名单、code 空或超长或重复
* (<b>含软删 code</b>——复用已废弃 code 会改写历史答卷的状态名)、label 空、
* 全部条目都被软删(新数据无状态可给)。
*/
public static String normalize(String statusDefsJson) {
if (statusDefsJson == null || statusDefsJson.trim().isEmpty()) {
return null;
}
JsonNode root;
try {
root = MAPPER.readTree(statusDefsJson);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义不是合法 JSON");
}
if (!root.isArray() || root.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义必须是非空 JSON 数组 [{code,label,is_deleted}]");
}
Set<String> codes = new HashSet<>();
ArrayNode out = MAPPER.createArrayNode();
boolean anyLive = false;
for (JsonNode node : root) {
if (!node.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义条目必须是对象");
}
Iterator<String> names = node.fieldNames();
while (names.hasNext()) {
String prop = names.next();
if (!ALLOWED_PROPS.contains(prop)) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义属性不在契约内:" + prop);
}
}
JsonNode codeNode = node.get("code");
String code = codeNode == null || codeNode.isNull() ? null : codeNode.asText();
if (code == null || code.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义缺少 code");
}
if (code.length() > MAX_CODE_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态 code 过长(≤" + MAX_CODE_LEN + "):" + code);
}
if (!codes.add(code)) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态 code 重复:" + code);
}
JsonNode labelNode = node.get("label");
String label = labelNode == null || labelNode.isNull() ? null : labelNode.asText();
if (label == null || label.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义缺少 label:" + code);
}
if (label.length() > MAX_LABEL_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态 label 过长(≤" + MAX_LABEL_LEN + "):" + code);
}
JsonNode deletedNode = node.get("is_deleted");
if (deletedNode != null && !deletedNode.isNull() && !deletedNode.isBoolean()) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义 is_deleted 必须是 true/false:" + code);
}
boolean deleted = isDeleted(deletedNode);
anyLive |= !deleted;
ObjectNode item = MAPPER.createObjectNode();
item.put("code", code);
item.put("label", label);
item.put("is_deleted", deleted);
out.add(item);
}
if (!anyLive) {
throw new BizException(ErrorCode.PARAM_ERROR, "至少要保留一个可用状态(全部废弃后新数据无状态可给)");
}
try {
return MAPPER.writeValueAsString(out);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义序列化失败");
}
}
private static boolean isDeleted(JsonNode deletedNode) {
return deletedNode != null && deletedNode.isBoolean() && deletedNode.booleanValue();
}
/** 读取生效集合:列值空=默认集合(V12),非空=解析后的自定义全集(含软删条目,供历史回显) */
public static List<StatusDef> effective(String statusDefsJson) {
String source = statusDefsJson == null || statusDefsJson.trim().isEmpty() ? DEFAULT_DEFS_JSON : statusDefsJson;
JsonNode root;
try {
root = MAPPER.readTree(source);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "状态定义损坏,请联系管理员");
}
List<StatusDef> result = new ArrayList<>();
if (root.isArray()) {
for (JsonNode node : root) {
JsonNode codeNode = node.get("code");
JsonNode labelNode = node.get("label");
JsonNode deletedNode = node.get("is_deleted");
result.add(new StatusDef(
codeNode == null ? null : codeNode.asText(),
labelNode == null ? null : labelNode.asText(),
isDeleted(deletedNode)));
}
}
return result;
}
/** 新答卷的初始状态(T3 提交时取):第一个未废弃的 code */
public static String defaultStatusCode(String statusDefsJson) {
for (StatusDef def : effective(statusDefsJson)) {
if (!def.isDeleted()) {
return def.getCode();
}
}
return null;
}
/** 状态名回显(含已废弃,V13 由调用方加"已废弃:"前缀);未定义的 code 返回 null */
public static String labelOf(String statusDefsJson, String statusCode) {
for (StatusDef def : effective(statusDefsJson)) {
if (def.getCode() != null && def.getCode().equals(statusCode)) {
return def.getLabel();
}
}
return null;
}
/** code 是否在生效集合(含软删定义)——T3/T4 改状态接口的把关口径 */
public static boolean contains(String statusDefsJson, String statusCode) {
for (StatusDef def : effective(statusDefsJson)) {
if (def.getCode() != null && def.getCode().equals(statusCode)) {
return true;
}
}
return false;
}
/** 可赋的新状态(未废弃):批量改状态/下拉候选用 */
public static List<StatusDef> liveDefs(String statusDefsJson) {
List<StatusDef> result = new ArrayList<>();
for (StatusDef def : effective(statusDefsJson)) {
if (!def.isDeleted()) {
result.add(def);
}
}
return result;
}
}
@@ -0,0 +1,202 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.formcenter.service.FormDictService;
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.util.List;
/**
* 定义域管理端(CAS 会话 + 定义管理权,{@link FormAdminGuard} 把关):
* 表单草稿/保存升版/发布/停用/恢复/软删/另存副本/表单级配置/状态定义,以及选项字典两表。
*
* <p>契约纪律:</p>
* <ul>
* <li>身份一律会话自取,<b>不接外部 schId 作为归属</b>——{@code schId} 参数只是超管跨校/平台级
* 上下文的"目标域"选择,校级管理员传别的值直接拒(契约 §7 身份行、V17 同族);</li>
* <li>出入参只出现 {@code uuid}/{@code code},不出现库内 {@code id}(契约 §1,V5);</li>
* <li>表单级配置与状态定义不进版本快照(方案 §5.1/§5.4),所以改它们不需要升版。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/form/admin")
@RequiredArgsConstructor
public class FormAdminController {
private final FormAdminGuard adminGuard;
private final FormDefService defService;
private final FormDictService dictService;
// ==================== 表单定义 ====================
@GetMapping("/list")
public List<FormModels.FormView> list(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam(required = false) String appCode,
@RequestParam(required = false) Integer status) {
return defService.list(adminGuard.requireAdmin(user, schId), appCode, status);
}
@GetMapping("/get")
public FormModels.FormView get(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.get(adminGuard.requireAdmin(user, schId), uuid);
}
@GetMapping("/versions")
public List<FormModels.VersionView> versions(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.versions(adminGuard.requireAdmin(user, schId), uuid);
}
/** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */
@GetMapping("/version")
public FormModels.VersionView version(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid,
@RequestParam("version") Integer version) {
return defService.version(adminGuard.requireAdmin(user, schId), uuid, version);
}
/**
* 筛选候选列(V3 的接口侧断言点):<b>只有显式声明 searchable 的字段</b>才进候选,
* 取当前发布版本的 schema;未发布时返回空列表(草稿字段不参与筛选)。
*/
@GetMapping("/filterFields")
public List<String> filterFields(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId), uuid);
return current == null ? java.util.Collections.emptyList()
: FormSchemaValidator.searchableKeys(current.getSchemaJson());
}
@PostMapping("/create")
public FormModels.FormView create(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCreateRequest body) {
return defService.create(adminGuard.requireAdmin(user, schId), body);
}
/** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */
@PostMapping("/save")
public FormModels.FormView save(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormSaveRequest body) {
return defService.save(adminGuard.requireAdmin(user, schId), body);
}
/** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */
@PostMapping("/publish")
public FormModels.FormView publish(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid,
@RequestParam(value = "changeLog", required = false) String changeLog) {
return defService.publish(adminGuard.requireAdmin(user, schId), uuid, changeLog);
}
/** 停用:只断填报入口,数据可读可导(方案 §5.4) */
@PostMapping("/disable")
public FormModels.FormView disable(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.disable(adminGuard.requireAdmin(user, schId), uuid);
}
/** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */
@PostMapping("/enable")
public FormModels.FormView enable(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.enable(adminGuard.requireAdmin(user, schId), uuid);
}
/** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */
@PostMapping("/softDelete")
public void softDelete(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
defService.softDelete(adminGuard.requireAdmin(user, schId), uuid);
}
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行 */
@PostMapping("/copy")
public FormModels.FormView copy(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCopyRequest body) {
return defService.copy(adminGuard.requireAdmin(user, schId), body);
}
/** 表单级配置:面向人群/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签 */
@PostMapping("/config")
public FormModels.FormView config(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormConfigRequest body) {
return defService.updateConfig(adminGuard.requireAdmin(user, schId), body);
}
/** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */
@PostMapping("/statusDefs")
public FormModels.FormView statusDefs(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.StatusDefsRequest body) {
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId), body);
}
// ==================== 选项字典(契约 §2.1) ====================
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
@GetMapping("/dict/list")
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) {
return dictService.listOwn(adminGuard.requireAdmin(user, schId));
}
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
@GetMapping("/dict/selectable")
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
return dictService.listSelectable(adminGuard.requireAdmin(user, schId));
}
@PostMapping("/dict/save")
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId()), body.getCode(), body.getName());
}
@PostMapping("/dict/delete")
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId()), body.getCode());
}
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
@PostMapping("/dict/item/save")
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId()),
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
}
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
@PostMapping("/dict/item/delete")
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId()), body.getDictCode(), body.getValue());
}
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
FormModels.FormView form = defService.get(schId, uuid);
if (form.getCurrentVersion() == null) {
return null;
}
return defService.version(schId, uuid, form.getCurrentVersion());
}
}
@@ -0,0 +1,60 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.FormModels;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 填报入口的寻址端点(契约 §1 resolve 语义,T7 填报页与 T4 oapi 的同一条解析路径)。
*
* <p>纪律:</p>
* <ul>
* <li><b>租户域取会话校,绝不接受外部传 schId</b>(契约 §7 身份行:任何身份注入被禁止;
* 契约 §1 校域不变式:{@code answer.sch_id} 恒=会话校);</li>
* <li>入口只用 {@code code}(人可读、跨环境稳定),返回体里的运行时身份是 {@code formUuid},
* 库内 {@code id} 不外露(契约 §1、V5);</li>
* <li>options 已按字典内联展开,字典本身不进对外契约(契约 §2.1)。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/form")
@RequiredArgsConstructor
public class FormResolveController {
/** 本应用 app_code 缺省值(单表单直落地址 {@code /form-center/f/{code}} 不带 appCode 时用它) */
private static final String DEFAULT_APP_CODE = "form-center";
private final FormDefService defService;
/**
* 解析当前发布版本。
*
* @param code 表单 code(入口/分享 URL 用的那一个)
* @param appCode 归属应用,缺省 form-center;快应用入口传快应用的 app_code
*/
@GetMapping("/resolve")
public FormModels.ResolvedFormView resolve(BoaUser user,
@RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode) {
return defService.resolve(requireSessionSchId(user),
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,103 @@
package com.yunhai.formcenter.entity;
import com.yunhai.formcenter.enums.FormEnums;
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;
/**
* 表单头表(契约 §10 / 方案 §5.1):一行=一条表单线,跨版本不变。
*
* <p>为什么必须有头表(方案 §5.1):uuid 要跨版本不变(V5)就不能长在版本行上;状态定义
* "属于表单、会被改、不随版本快照"(§5.4);"当前发布版本"指针是 form 级;草稿行与发布行是
* 同一头表下两种生命周期的版本行。</p>
*
* <p>标识分工(契约 §1):{@code id} 只做库内定位、绝不外露;{@code uuid}=数据面运行时身份;
* {@code code}=配置期引用 + 入口寻址,同校 + 同 {@code app_code} 内唯一、发布后不可改。</p>
*
* <p>表单级配置(audience/fill_limit/deadline/answer_no_enabled/status_defs/group_tag)不进版本
* 快照——升版只换 schema 行,这些列原地可改(方案 §5.1/§5.4)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form",
uniqueConstraints = {
@UniqueConstraint(name = "uk_form_uuid", columnNames = "uuid"),
// app_code 必须进唯一键(方案 §5.1 D8):中心同时装多个消费方的表单,
// 漏了命名空间则两个消费方同校同 code 直接撞(V6 的反面)
@UniqueConstraint(name = "uk_form", columnNames = {"sch_id", "app_code", "code"})
},
indexes = @Index(name = "idx_form_app", columnList = "sch_id, app_code, status"))
public class Form extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 中心/平台级表单的归属 app_code(手册 §1:app_code=form-center) */
public static final String CENTER_APP_CODE = "form-center";
/** 平台级行的租户键(契约 §1:域=会话校 ∪ 平台级 sch_id=0,本校遮蔽平台) */
public static final long PLATFORM_SCH_ID = 0L;
/** 数据面运行时身份:建草稿即生成、跨版本不变(契约 §1) */
@Column(name = "uuid", nullable = false, length = 32, updatable = false)
private String uuid;
/** 租户键;0=平台级表单(U5/U6,超管上下文建,学校可复制使用) */
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
/** 归属应用:中心/平台级挂 form-center,快应用挂其 app_code(契约 §1) */
@Column(name = "app_code", nullable = false, length = 64)
private String appCode = CENTER_APP_CODE;
/** 人可读编码,≤64,同校同 app_code 内唯一;发布后不可改(改=换表单,契约 §1) */
@Column(name = "code", nullable = false, length = 64)
private String code;
/** 表单名 */
@Column(name = "name", nullable = false, length = 128)
private String name;
/** 当前发布版本指针,null=从未发布(resolve 只认它,方案 §5.1) */
@Column(name = "current_version")
private Integer currentVersion;
/** 生命周期:0 草稿 / 1 已发布 / 2 停用(契约 §10) */
@Column(name = "status", nullable = false, columnDefinition = "tinyint")
private Integer status = FormEnums.FormStatus.DRAFT.getCode();
/** 状态定义 JSON {@code [{code,label,is_deleted}]},null=用默认"待处理/已处理"(V12,方案 §5.3) */
@Column(name = "status_defs", columnDefinition = "text")
private String statusDefs;
/** 面向人群:1 学生 / 2 教师(数据范围默认矩阵的输入,契约 §10) */
@Column(name = "audience", nullable = false, columnDefinition = "tinyint")
private Integer audience = FormEnums.Audience.STUDENT.getCode();
/** 限填次数:0 不限 / 1 限一次(去重维度按 subject/submitter 推导,方案 §5.4) */
@Column(name = "fill_limit", nullable = false, columnDefinition = "tinyint")
private Integer fillLimit = FormEnums.FillLimit.UNLIMITED.getCode();
/** 收集截止时间,datetime 粒度(管理员只选日期时默认当日 23:59:59,方案 §5.4) */
@Column(name = "deadline")
private LocalDateTime deadline;
/** 报名号开关(默认关;启用后 T3 提交生成 {code}-{YYYYMMDD}-{当日序号},方案 §5.4) */
@Column(name = "answer_no_enabled", nullable = false, columnDefinition = "tinyint")
private Integer answerNoEnabled = 0;
/** 多表单快应用的菜单分组标签(方案 §5.1,可选) */
@Column(name = "group_tag", length = 64)
private String groupTag;
/** 软删标记——唯一删除形态,有答卷的表单禁物理删除(方案 §5.4) */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
}
@@ -0,0 +1,41 @@
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;
/**
* 选项字典(契约 §2.1 / 方案 §4.3③b):两级——{@code sch_id=0} 平台级(超管维护,
* 学校<b>可见、可用、不可改</b>),其余为校级(仅本校表单可引用,sch_id 天然隔离)。
*
* <p>{@code code} 是 schema 里 {@code options.source="dict:{code}"} 的引用键,因此建后不可改
* (改 code=换字典,会打穿所有引用它的表单)。{@code is_deleted} 软删后不再可被新引用,
* 但历史 schema 的降级显示由读路径负责。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_dict",
uniqueConstraints = @UniqueConstraint(name = "uk_dict", columnNames = {"sch_id", "code"}))
public class FormDict extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 0=平台级,其余=校级(契约 §2.1) */
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
/** 引用键,被 schema 以 {@code dict:{code}} 寻址,建后不可改 */
@Column(name = "code", nullable = false, length = 64, updatable = false)
private String code;
@Column(name = "name", nullable = false, length = 128)
private String name;
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
}
@@ -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;
/**
* 字典条目(契约 §2.1 / 方案 §4.3③b):<b>value 不可变、label 可改</b>(D11 同款保护——字典改名
* 不打穿历史答卷,这是字典机制能安全存在的前提)。
*
* <p>软删条目({@code is_deleted=1}):<b>value 仍通过提交校验</b>(旧答卷可原样重存,V38),
* 仅设计器不可新选。因此本表对外有两个读取口径——
* {@code 设计器可选=未删} 与 {@code 提交放行=含已删},勿混用。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_dict_item",
uniqueConstraints = @UniqueConstraint(name = "uk_dict_item", columnNames = {"dict_id", "value"}),
indexes = @Index(name = "idx_dict_item_sort", columnList = "dict_id, sort"))
public class FormDictItem extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 所属字典(逻辑外键) */
@Column(name = "dict_id", nullable = false, updatable = false)
private Long dictId;
/** 答案存的就是它;建一次即不可变(uk(dict_id,value) 兜住同名重写) */
@Column(name = "value", nullable = false, length = 64, updatable = false)
private String value;
/** 展示名,随便改 */
@Column(name = "label", nullable = false, length = 128)
private String label;
/** 展示顺序(升序;同值按 id 稳定排) */
@Column(name = "sort", nullable = false)
private Integer sort = 0;
/** 软删:设计器不可新选,提交校验仍放行 */
@Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint")
private Integer isDeleted = 0;
}
@@ -0,0 +1,65 @@
package com.yunhai.formcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 表单版本行(契约 §10 / 方案 §3.2):{@code (sch_id, app_code, code, version)} 唯一确定一个
* <b>永不改变</b>的版本行——已发布行禁止就地修改,改版=新增行(V7)。
*
* <p>草稿行与发布行同表(方案 §5.1):区分依据是"是否已发布过",即
* {@code publish_time != null},以及头表 {@code form.current_version} 指针是否指到它。
* 唯一可写的例外是<b>未发布</b>草稿行的 {@code schema_json}/{@code change_log}(草稿直改),
* 发布动作只补 {@code publish_time}——发布后该行内容逐字节冻结,缓存声明才成立(方案 §3.2
* "schema_json 内容不可变→可长缓存;resolve 结果会因升版失效→短 TTL")。</p>
*
* <p>字典引用 {@code options.source="dict:{code}"} 原样存在 schema_json 里,<b>不进快照展开</b>
* (方案 §4.3③b:活引用是它的价值,读接口下发时才内联展开)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "form_version",
uniqueConstraints = @UniqueConstraint(name = "uk_form_ver",
columnNames = {"sch_id", "app_code", "code", "version"}),
indexes = @Index(name = "idx_ver_form", columnList = "form_id"))
public class FormVersion extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 所属表单头表(逻辑外键,无物理 FK——方案 §7.7) */
@Column(name = "form_id", nullable = false, updatable = false)
private Long formId;
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
@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;
/** 版本:只增不改(契约 §1) */
@Column(name = "version", nullable = false, updatable = false)
private Integer version;
/** schema 快照:{@code {"groups":[{key,label,fields:[…]}]}},数组序即展示序(契约 §2) */
@Column(name = "schema_json", nullable = false, columnDefinition = "longtext")
private String schemaJson;
/** 升版改动说明(可空,≤500) */
@Column(name = "change_log", length = 500)
private String changeLog;
/** 发布时间;null=尚未发布(草稿行) */
@Column(name = "publish_time")
private LocalDateTime publishTime;
}
@@ -0,0 +1,90 @@
package com.yunhai.formcenter.enums;
/**
* 定义域枚举(契约 §1/§10 + 方案 §5.1/§5.4,一期冻结)。
* 列值直接用契约 §10 DDL 草案里的数字口径,避免库内值与文档两套。
*/
public final class FormEnums {
private FormEnums() {
}
/** 表单生命周期:0 草稿 / 1 已发布 / 2 停用(停用=填报入口失效,数据可看可导,方案 §5.4) */
public enum FormStatus {
DRAFT(0), PUBLISHED(1), DISABLED(2);
private final int code;
FormStatus(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static String nameOf(Integer code) {
if (code != null) {
for (FormStatus s : values()) {
if (s.code == code) {
return s.name();
}
}
}
return null;
}
}
/** 面向人群:1 面向学生 / 2 面向教师(数据范围默认矩阵的输入,方案 §6.4;只决定默认,授权仍看模块行) */
public enum Audience {
STUDENT(1), TEACHER(2);
private final int code;
Audience(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static boolean valid(Integer code) {
if (code != null) {
for (Audience a : values()) {
if (a.code == code) {
return true;
}
}
}
return false;
}
}
/** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */
public enum FillLimit {
UNLIMITED(0), ONCE(1);
private final int code;
FillLimit(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static boolean once(Integer code) {
return code != null && code == ONCE.code;
}
}
/**
* 限一次的去重维度(方案 §5.4 v1.2 定死,不是配置项而是推导结果):
* {@code subject_id} 非空按 subject 限(代填场景"一人一份"),否则按 submitter 限。
*/
public enum LimitScope {
SUBJECT, SUBMITTER
}
}
@@ -0,0 +1,196 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 定义域出入参模型(契约 §1/§2/§10 的响应侧形状)。
*
* <p><b>响应体一律不带 {@code id}</b>:契约 §1 把 id 定为"库内定位、绝不外露",V5 要断言
* "id 不出现在任何响应外层"。因此管理端与数据面都用 {@code uuid} 寻址表单;字典没有 uuid,
* 用 {@code (域, code)} 寻址、条目用 {@code (dictCode, value)}(value 建后不可变,天然是稳定键)。</p>
*/
public final class FormModels {
private FormModels() {
}
/** 表单项(头表 + 版本指针,不含 schema 正文) */
@Getter
@Setter
public static class FormView {
private String formUuid;
private Long schId;
private String appCode;
private String code;
private String name;
private Integer status;
/** DRAFT/PUBLISHED/DISABLED(枚举名,前端不必再映射数字) */
private String statusName;
/** 当前发布版本,null=从未发布 */
private Integer currentVersion;
/** 待发布的草稿版本号,null=无草稿 */
private Integer draftVersion;
private Integer audience;
private Integer fillLimit;
/** yyyy-MM-dd HH:mm:ss,null=不限 */
private String deadline;
private Integer answerNoEnabled;
private String groupTag;
private List<StatusDefView> statusDefs;
private String createTime;
private String updateTime;
}
/** 版本行(schema 正文按需返回) */
@Getter
@Setter
public static class VersionView {
private String formUuid;
private Integer version;
/** 该行是否已发布(publish_time 非空)——已发布行内容逐字节冻结(V7) */
private Boolean published;
private String changeLog;
private String publishTime;
private String createTime;
private String updateTime;
/** schema_json 原文;列表接口不回填,单版本查询才给 */
private String schemaJson;
}
/**
* resolve 结果(契约 §1 的读入口,T7 填报页/T4 oapi 共用形状):
* options 已按字典<b>内联展开</b>——字典是中心内部组织方式,不进对外契约(契约 §2.1)。
*/
@Getter
@Setter
public static class ResolvedFormView {
private String formUuid;
private String code;
private String appCode;
private String name;
private Integer version;
private Integer audience;
private Integer fillLimit;
private Integer answerNoEnabled;
private String deadline;
/** 是否已过截止时间(只读态判定,契约 §5"过期后页面只读") */
private Boolean deadlineReached;
private List<StatusDefView> statusDefs;
/** 展开后的 groups 原文(数组序即展示序) */
private Object groups;
}
/** 状态定义条目:存储形状 {@code [{code,label,is_deleted}]}(契约 §10) */
@Getter
@Setter
public static class StatusDefView {
private String code;
private String label;
@JsonProperty("is_deleted")
private boolean deleted;
}
/** 字典(两级:schId=0 平台级 / 其余校级) */
@Getter
@Setter
public static class DictView {
private Long schId;
/** true=平台级(学校可见可用不可改,契约 §2.1) */
private Boolean platformLevel;
private String code;
private String name;
private List<DictItemView> items;
}
/** 字典条目(value 不可变;deleted=true 时设计器不可新选但提交仍放行) */
@Getter
@Setter
public static class DictItemView {
private String value;
private String label;
private Integer sort;
private Boolean deleted;
}
/** 新建草稿 */
@Getter
@Setter
public static class FormCreateRequest {
/** 缺省=form-center(中心自己的表单);快应用表单挂其 app_code */
private String appCode;
private String code;
private String name;
/** 可空:先建表单再画字段 */
private String schemaJson;
}
/** 保存:草稿直改;已发布表单的保存=升版(新增版本行,老行不动,V7) */
@Getter
@Setter
public static class FormSaveRequest {
private String uuid;
/** 仅"从未发布"时可改;发布后不可改(契约 §1) */
private String code;
private String name;
private String schemaJson;
private String changeLog;
}
/** 表单级配置(不进版本快照,方案 §5.1) */
@Getter
@Setter
public static class FormConfigRequest {
private String uuid;
private Integer audience;
private Integer fillLimit;
/** yyyy-MM-dd(补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串/null=不限 */
private String deadline;
private Integer answerNoEnabled;
private String groupTag;
}
/** 状态定义(V12) */
@Getter
@Setter
public static class StatusDefsRequest {
private String uuid;
private List<StatusDefView> statusDefs;
}
/** 另存为副本(V41) */
@Getter
@Setter
public static class FormCopyRequest {
/** 源表单 */
private String uuid;
/** 副本 app_code,缺省沿用源表单归属 */
private String appCode;
private String code;
private String name;
}
/** 字典新建/改名(code 建后不可改:它是 schema 的引用键) */
@Getter
@Setter
public static class DictSaveRequest {
/** 仅超管可用(0=平台级字典);校级管理员忽略本字段、恒为本校 */
private Long schId;
private String code;
private String name;
}
/** 字典条目新建/改 label(value 建后不可变,兼作定位键) */
@Getter
@Setter
public static class DictItemRequest {
private Long schId;
private String dictCode;
private String value;
private String label;
private Integer sort;
}
}
@@ -0,0 +1,25 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormDictItem;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 字典条目仓库。两个读取口径务必分清(契约 §2.1):
* <ul>
* <li>{@code …AndIsDeleted(…, 0)} = 设计器可选/内联展开(软删条目不再出现在候选里);</li>
* <li>{@code …ByDictId…}(不带 is_deleted)= <b>提交校验放行集</b>,含软删条目——
* 旧答卷能原样重存(V38)全靠这一条。</li>
* </ul>
*/
public interface FormDictItemRepository extends JpaRepository<FormDictItem, Long> {
List<FormDictItem> findByDictIdOrderBySortAscIdAsc(Long dictId);
List<FormDictItem> findByDictIdAndIsDeletedOrderBySortAscIdAsc(Long dictId, Integer isDeleted);
/** value 建后不可变,故 (dict_id, value) 即条目的稳定定位键(对外不必外露 id) */
Optional<FormDictItem> findByDictIdAndValue(Long dictId, String value);
}
@@ -0,0 +1,30 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormDict;
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;
/**
* 字典仓库。读取分两类:管理端按租户键取本层;schema 解析按契约 §2.1 的域
* ({@code sch_id ∈ {会话校, 0}},本校遮蔽平台)取候选后在应用层判遮蔽——
* 遮蔽是"同 code 两行都存在时本校胜出",SQL 里写 ORDER BY 也能做,但把规则留在
* 一处更好读,且表单数量级小(P10:不引入 5.7 之外的语法)。
*/
public interface FormDictRepository extends JpaRepository<FormDict, Long> {
Optional<FormDict> findBySchIdAndCode(Long schId, String code);
List<FormDict> findBySchIdAndIsDeletedOrderByCodeAsc(Long schId, Integer isDeleted);
/** 设计器选择器取域:会话校 ∪ 平台级(遮蔽规则在应用层按 code 判) */
List<FormDict> findBySchIdInAndIsDeletedOrderByCodeAsc(Collection<Long> schIds, Integer isDeleted);
@Query("select d from FormDict d where d.code = :code and d.schId in (:schIds) and d.isDeleted = 0")
List<FormDict> findResolveCandidates(@Param("code") String code,
@Param("schIds") Collection<Long> schIds);
}
@@ -0,0 +1,38 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Form;
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;
/**
* 表单头表仓库。查询一律带租户键(sch_id)与软删过滤;resolve 例外——它按契约 §1 的
* "会话校 ∪ 平台级(0)" 取域,遮蔽规则在应用层判(两条同 code 分属平台/本校时本校胜出)。
* JPQL 无 CTE/WITH、无函数索引依赖,MySQL 5.7 兼容(P10)。
*/
public interface FormRepository extends JpaRepository<Form, Long> {
/** 唯一键定位(契约 §1:同校 + 同 app_code 内 code 唯一);软删行也在内,好给出"code 被占用"的点名提示 */
Optional<Form> findBySchIdAndAppCodeAndCode(Long schId, String appCode, String code);
Optional<Form> findByUuidAndSchId(String uuid, Long schId);
/**
* 管理端列表:本校未删表单(软删行不进列表,方案 §5.4)。
* app_code/status 过滤在应用层做——一所学校的表单量级是个位数到几百,
* 不值得为它写 {@code :p is null} 型 JPQL(可空参数在 MySQL 上的类型推断有坑,P10 保守口径)。
*/
@Query("select f from Form f where f.schId = :schId and f.isDeleted = 0 order by f.updateTime desc, f.id desc")
List<Form> findLiveBySch(@Param("schId") Long schId);
/** resolve 取域:会话校 ∪ 平台级(契约 §1 / 方案 §4.1 校域不变式) */
@Query("select f from Form f where f.code = :code and f.appCode = :appCode "
+ "and f.schId in (:schIds) and f.isDeleted = 0")
List<Form> findResolveCandidates(@Param("code") String code,
@Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds);
}
@@ -0,0 +1,36 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.FormVersion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 版本行仓库:<b>没有任何"修改已发布行"的方法</b>——行不可变由服务层的写路径共同保证
* (V7)。这里只提供定位与 max(version) 取号。
*/
public interface FormVersionRepository extends JpaRepository<FormVersion, Long> {
List<FormVersion> findByFormIdOrderByVersionDesc(Long formId);
Optional<FormVersion> findByFormIdAndVersion(Long formId, Integer version);
/** 未发布的草稿行(同一表单最多一条:发布即补 publish_time,方案 §5.1 两种生命周期) */
Optional<FormVersion> findFirstByFormIdAndPublishTimeIsNullOrderByVersionDesc(Long formId);
/** 列表页"有未发布草稿"徽标:一次取回当页全部表单的草稿行,避免 N+1 */
List<FormVersion> findByFormIdInAndPublishTimeIsNull(java.util.Collection<Long> formIds);
/** code 变更同步(仅限从未发布的表单,见 FormDefService#changeCode):bulk update,不碰已发布行 */
@Modifying(clearAutomatically = true)
@Query("update FormVersion v set v.code = :code where v.formId = :formId and v.publishTime is null")
int updateCodeOnDrafts(@Param("formId") Long formId, @Param("code") String code);
/** 取号:本表单历史最大版本(无版本行=0,新建首版=1) */
@Query("select coalesce(max(v.version), 0) from FormVersion v where v.formId = :formId")
int maxVersion(@Param("formId") Long formId);
}
@@ -0,0 +1,72 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
/**
* 定义域管理面守卫(方案 §6.5:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。
*
* <p>本任务(T2)按平台标准角色闸门落地,与 fork 模板 flow-center 的
* {@code FlowAdminGuard} 完全同口径:超管({@code type==1})可跨校、且是唯一能操作
* 平台级行({@code sch_id=0}:平台级表单/平台级字典,方案 §6.5、契约 §2.1)的身份;
* 校级管理员(roleId∈{2 校领导,5 局领导,7 管理员})限本校。</p>
*
* <p><b>缺口记账</b>:方案 §6.1 的第②层"模块授权(表单设计/字典管理模块行)"要把把关
* 从角色换成授权模块集,依赖 GetUserMod 的模块树消费(T4 的 customId 扫描与 T8 的
* 前端把关同一条链路),本轮不在此实现 → 问题单登记。</p>
*/
@Service
public class FormAdminGuard {
private static final int SUPER_ADMIN_TYPE = 1;
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
/**
* 校验定义管理权并返回本次操作的租户域。
*
* @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行)
* @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒
*/
public Long requireAdmin(BoaUser user, Long targetSchId) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) {
if (targetSchId != null) {
return targetSchId;
}
Long current = currentSchId(user);
if (current != null) {
return current;
}
throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
}
boolean roleMatch = user.getRoleIds() != null
&& user.getRoleIds().stream().anyMatch(ADMIN_ROLE_IDS::contains);
Long current = currentSchId(user);
if (!roleMatch || current == null) {
throw new BizException(ErrorCode.FORBIDDEN, "无表单定义管理权限");
}
if (targetSchId != null && !targetSchId.equals(current)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅可管理本校表单与字典");
}
if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) {
// 平台级行=跨校资产,校级管理员即便把本校 id 传成 0 也不给(方案 §6.5)
throw new BizException(ErrorCode.FORBIDDEN, "平台级表单/字典仅超管可维护");
}
return current;
}
private Long currentSchId(BoaUser user) {
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
return null;
}
return Long.valueOf(user.getCurrentRole().getSchId());
}
}
@@ -0,0 +1,627 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.common.FormStatusDefs;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormVersion;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.formcenter.repository.FormVersionRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 定义域服务(契约 §1/§2/§10,方案 §3.2/§5.1/§5.4)。
*
* <p><b>版本模型(V7 的落点)</b>:{@code form_version} 行一旦发布即逐字节冻结,改版=新增行。
* 唯一的两种"写已存在版本行"的通道是:①未发布草稿行的直改(schema_json/change_log);
* ②发布动作补 {@code publish_time}。两者都不触碰已发布内容。</p>
*
* <p><b>草稿与发布并存</b>:已发布表单保存新 schema 时开 {@code version=max+1} 的草稿行,
* 头表 {@code current_version}/{@code status} <b>不动</b>——于是升版瞬间在途填报仍按旧版走
* (契约 §5"按加载版本校验钉版"天然成立),发布动作才把指针推过去。</p>
*
* <p><b>表单级配置</b>(audience/fill_limit/deadline/answer_no_enabled/status_defs/group_tag)
* 是头表列、原地可改、<b>不进版本快照</b>(方案 §5.1/§5.4)。{@code name} 也只在头表,
* 所以改名不升版。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FormDefService {
/**
* code 形态:契约 §1 只写"人可读串 ≤64"。它要进入口 URL({@code /form-center/f/{code}})
* 与配置引用,故取 URL-safe 保守子集(字母/数字/下划线/连字符)→ 问题单登记。
*/
private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-]{1,64}$");
/** 新建表单的占位 schema:分组与字段都在草稿里长出来,发布时必过校验器 */
private static final String EMPTY_SCHEMA = "{\"groups\":[]}";
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormRepository formRepository;
private final FormVersionRepository versionRepository;
private final FormDictService dictService;
// ==================== 读 ====================
/** 管理端列表(本校未删),appCode/status 可选过滤 */
public List<FormModels.FormView> list(Long schId, String appCode, Integer status) {
List<Form> rows = formRepository.findLiveBySch(schId);
List<Form> filtered = new ArrayList<>();
for (Form row : rows) {
if (appCode != null && !appCode.trim().isEmpty() && !appCode.trim().equals(row.getAppCode())) {
continue;
}
if (status != null && !status.equals(row.getStatus())) {
continue;
}
filtered.add(row);
}
Map<Long, Integer> draftVersions = draftVersions(filtered);
List<FormModels.FormView> result = new ArrayList<>();
for (Form row : filtered) {
result.add(toView(row, draftVersions.get(row.getId())));
}
return result;
}
/** 表单详情(按 uuid,本校范围;uuid 不可枚举,契约 §1) */
public FormModels.FormView get(Long schId, String uuid) {
Form form = requireForm(schId, uuid);
return toView(form, draftVersion(form.getId()));
}
/** 版本历史(降序;不给 schema 正文,避免列表把 LONGTEXT 全捞出来) */
public List<FormModels.VersionView> versions(Long schId, String uuid) {
Form form = requireForm(schId, uuid);
List<FormModels.VersionView> result = new ArrayList<>();
for (FormVersion row : versionRepository.findByFormIdOrderByVersionDesc(form.getId())) {
result.add(toVersionView(form, row, false));
}
return result;
}
/** 单个版本行(编辑回显/审计取证用,给 schema 正文) */
public FormModels.VersionView version(Long schId, String uuid, Integer version) {
Form form = requireForm(schId, uuid);
FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), version)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "版本不存在:" + version));
return toVersionView(form, row, true);
}
/**
* resolve(契约 §1):按 (会话校, app_code, code) 解析<b>当前发布版本</b>,
* 域=会话校 ∪ 平台级(sch_id=0)且<b>本校遮蔽平台</b>——读接口的唯一入口。
* options 已内联展开(字典不进对外契约,契约 §2.1)。
*/
public FormModels.ResolvedFormView resolve(Long schId, String appCode, String code) {
String cleanApp = normalizeAppCode(appCode);
String cleanCode = code == null ? null : code.trim();
if (cleanCode == null || cleanCode.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "code 不能为空");
}
Form form = pickByShadowing(
formRepository.findResolveCandidates(cleanCode, cleanApp, resolveDomain(schId)), schId);
if (form == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + cleanCode);
}
if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode()
|| form.getCurrentVersion() == null) {
// 业务规则类(契约 §6 末段):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4)
throw new BizException(ErrorCode.CUSTOM, form.getStatus() != null
&& form.getStatus() == FormEnums.FormStatus.DISABLED.getCode()
? "表单已停用,不再接收填报:" + cleanCode : "表单尚未发布:" + cleanCode);
}
FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), form.getCurrentVersion())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
"当前发布版本行缺失(version=" + form.getCurrentVersion() + "),请联系管理员"));
return toResolvedView(form, row, dictService.expandSchema(form.getSchId(), row.getSchemaJson()));
}
// ==================== 写:草稿/发布/生命周期 ====================
/** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
@Transactional
public FormModels.FormView create(Long schId, FormModels.FormCreateRequest req) {
String cleanApp = normalizeAppCode(req.getAppCode());
String cleanCode = requireCode(req.getCode());
String cleanName = requireName(req.getName());
String schemaJson = req.getSchemaJson();
if (schemaJson != null && !schemaJson.trim().isEmpty()) {
FormSchemaValidator.validateSchema(schemaJson);
} else {
schemaJson = EMPTY_SCHEMA;
}
assertCodeAvailable(schId, cleanApp, cleanCode, null);
Form form = new Form();
form.setUuid(newUuid());
form.setSchId(schId);
form.setAppCode(cleanApp);
form.setCode(cleanCode);
form.setName(cleanName);
form.setStatus(FormEnums.FormStatus.DRAFT.getCode());
form.setCurrentVersion(null);
try {
form = formRepository.save(form);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单创建冲突(code 可能正被他人同时新建),请刷新后重试");
}
versionRepository.save(newVersionRow(form, 1, schemaJson, null));
return toView(form, 1);
}
/**
* 保存(设计器"保存"):
* <ul>
* <li>无草稿行 → 在草稿行上直改(未发布内容可反复改,V7 只冻结已发布行);</li>
* <li>已发布过且无草稿行 → 开 {@code max+1} 新行,头表指针与状态不动(在途提交按旧版,契约 §5);</li>
* <li>{@code schemaJson} 为空 → 只改头表元数据(name/code),不产生版本行——改名不升版;</li>
* <li>code 仅"从未发布"时可改(契约 §1),改时同步草稿行冗余 code(uk_form_ver 含 code)。</li>
* </ul>
*/
@Transactional
public FormModels.FormView save(Long schId, FormModels.FormSaveRequest req) {
Form form = requireForm(schId, req.getUuid());
if (req.getName() != null && !req.getName().trim().isEmpty()) {
form.setName(requireName(req.getName()));
}
if (req.getCode() != null && !req.getCode().trim().isEmpty()
&& !req.getCode().trim().equals(form.getCode())) {
changeCode(form, req.getCode().trim());
}
Integer draftVersion = draftVersion(form.getId());
String schemaJson = req.getSchemaJson();
if (schemaJson != null && !schemaJson.trim().isEmpty()) {
FormSchemaValidator.validateSchema(schemaJson);
if (form.getCurrentVersion() != null) {
FormVersion published = requireVersionRow(form.getId(), form.getCurrentVersion());
FormSchemaValidator.assertFieldTypesStable(published.getSchemaJson(), schemaJson);
}
FormVersion draft = draftVersion == null
? versionRepository.save(newVersionRow(form, versionRepository.maxVersion(form.getId()) + 1,
schemaJson, trimLog(req.getChangeLog())))
: updateDraft(form.getId(), draftVersion, schemaJson, req.getChangeLog());
draftVersion = draft.getVersion();
} else if (req.getChangeLog() != null && draftVersion != null) {
updateDraft(form.getId(), draftVersion, null, req.getChangeLog());
}
try {
form = formRepository.save(form);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突(code 或版本撞唯一键),请刷新后重试");
}
return toView(form, draftVersion);
}
/**
* 发布:把草稿行钉成不可变版本(补 publish_time)、头表指针推过去、生命周期置"已发布"。
* 三道校验:schema 静态契约(V2/V3)+ 跨版本字段身份稳定(V7/V8 前提)+ 字典引用可解析(V38 前提)。
*/
@Transactional
public FormModels.FormView publish(Long schId, String uuid, String changeLog) {
Form form = requireForm(schId, uuid);
FormVersion draft = versionRepository.findFirstByFormIdAndPublishTimeIsNullOrderByVersionDesc(form.getId())
.orElseThrow(() -> new BizException(ErrorCode.CUSTOM,
form.getCurrentVersion() == null ? "表单还没有可发布的草稿版本"
: "没有待发布的草稿版本;已发布表单请先到设计器保存升版,停用后恢复请走启用"));
validateForPublish(form, draft);
draft.setPublishTime(LocalDateTime.now());
if (changeLog != null && !changeLog.trim().isEmpty()) {
draft.setChangeLog(trimLog(changeLog));
}
versionRepository.save(draft);
form.setCurrentVersion(draft.getVersion());
form.setStatus(FormEnums.FormStatus.PUBLISHED.getCode());
formRepository.save(form);
return toView(form, null);
}
/** 停用:只断填报入口,数据可看可导、读接口不变(方案 §5.4 引用影响边界声明) */
@Transactional
public FormModels.FormView disable(Long schId, String uuid) {
Form form = requireForm(schId, uuid);
if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.CUSTOM, "仅已发布的表单可停用(当前:"
+ FormEnums.FormStatus.nameOf(form.getStatus()) + ")");
}
form.setStatus(FormEnums.FormStatus.DISABLED.getCode());
formRepository.save(form);
return toView(form, draftVersion(form.getId()));
}
/** 停用恢复:重新发布=恢复当前版本(方案 §5.4),不产生新版本行;恢复前重跑发布校验 */
@Transactional
public FormModels.FormView enable(Long schId, String uuid) {
Form form = requireForm(schId, uuid);
if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.DISABLED.getCode()) {
throw new BizException(ErrorCode.CUSTOM, "仅停用的表单可恢复;草稿请走发布");
}
if (form.getCurrentVersion() == null) {
throw new BizException(ErrorCode.CUSTOM, "该表单没有当前发布版本,无法恢复");
}
FormVersion current = requireVersionRow(form.getId(), form.getCurrentVersion());
// 停用期间字典可能被动过(条目/字典删除),恢复时按发布同口径再拦一次,避免开出无候选项的入口
validateForPublish(form, current);
form.setStatus(FormEnums.FormStatus.PUBLISHED.getCode());
formRepository.save(form);
return toView(form, draftVersion(form.getId()));
}
/** 软删表单:唯一删除形态(有答卷的表单禁物理删除,方案 §5.4);版本行一律不动 */
@Transactional
public void softDelete(Long schId, String uuid) {
Form form = requireForm(schId, uuid);
form.setIsDeleted(1);
formRepository.save(form);
}
/** 表单级配置(不进版本快照);deadline 只给日期时补当日 23:59:59(方案 §5.4) */
@Transactional
public FormModels.FormView updateConfig(Long schId, FormModels.FormConfigRequest req) {
Form form = requireForm(schId, req.getUuid());
if (req.getAudience() != null) {
if (!FormEnums.Audience.valid(req.getAudience())) {
throw new BizException(ErrorCode.PARAM_ERROR, "audience 只能是 1(面向学生)或 2(面向教师)");
}
form.setAudience(req.getAudience());
}
if (req.getFillLimit() != null) {
if (req.getFillLimit() != 0 && req.getFillLimit() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "fillLimit 只能是 0(不限)或 1(限一次)");
}
form.setFillLimit(req.getFillLimit());
}
if (req.getDeadline() != null) {
form.setDeadline(FormRuntimeRules.parseDeadline(req.getDeadline()));
}
if (req.getAnswerNoEnabled() != null) {
if (req.getAnswerNoEnabled() != 0 && req.getAnswerNoEnabled() != 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerNoEnabled 只能是 0 或 1");
}
form.setAnswerNoEnabled(req.getAnswerNoEnabled());
}
if (req.getGroupTag() != null) {
String tag = req.getGroupTag().trim();
if (tag.length() > 64) {
throw new BizException(ErrorCode.PARAM_ERROR, "groupTag 过长(≤64)");
}
form.setGroupTag(tag.isEmpty() ? null : tag);
}
formRepository.save(form);
return toView(form, draftVersion(form.getId()));
}
/** 状态定义(V12):传 null=回到默认"待处理/已处理";传空数组被校验器拒绝 */
@Transactional
public FormModels.FormView updateStatusDefs(Long schId, FormModels.StatusDefsRequest req) {
Form form = requireForm(schId, req.getUuid());
String json = null;
if (req.getStatusDefs() != null) {
try {
json = FormSchemaValidator.mapper().writeValueAsString(req.getStatusDefs());
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "状态定义序列化失败");
}
}
form.setStatusDefs(FormStatusDefs.normalize(json));
formRepository.save(form);
return toView(form, draftVersion(form.getId()));
}
/**
* 另存为副本(V41,方案 §5.4 v1.3 定一期):新 code / 新 uuid / version 从 1 起、
* schema 深拷贝、字典引用随行(引用是 {@code dict:{code}} 形态的活引用,天然跟 schema 走)。
* 源表单及其历史答卷零改动——本方法对源表只读。
*/
@Transactional
public FormModels.FormView copy(Long schId, FormModels.FormCopyRequest req) {
Form source = requireForm(schId, req.getUuid());
FormVersion sourceRow = copySourceVersion(source);
String cleanApp = req.getAppCode() == null || req.getAppCode().trim().isEmpty()
? source.getAppCode() : normalizeAppCode(req.getAppCode());
String cleanCode = requireCode(req.getCode());
String cleanName = requireName(req.getName());
assertCodeAvailable(schId, cleanApp, cleanCode, null);
Form copy = new Form();
copy.setUuid(newUuid());
copy.setSchId(schId);
copy.setAppCode(cleanApp);
copy.setCode(cleanCode);
copy.setName(cleanName);
copy.setStatus(FormEnums.FormStatus.DRAFT.getCode());
copy.setCurrentVersion(null);
// 表单级配置随副本(同校复制的目的就是"基于旧表单改一张"),但收集窗口不继承:
// 旧截止日期带进副本会立刻过期,一出生就读只(方案 §5.4 截止语义)
copy.setAudience(source.getAudience());
copy.setFillLimit(source.getFillLimit());
copy.setStatusDefs(source.getStatusDefs());
copy.setAnswerNoEnabled(source.getAnswerNoEnabled());
copy.setGroupTag(source.getGroupTag());
copy.setDeadline(null);
try {
copy = formRepository.save(copy);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "副本创建冲突(code 可能正被同时新建),请刷新后重试");
}
versionRepository.save(newVersionRow(copy, 1, sourceRow.getSchemaJson(),
"另存为副本,源表单 " + source.getAppCode() + "/" + source.getCode() + " v" + sourceRow.getVersion()));
return toView(copy, 1);
}
// ==================== 内部 ====================
private void validateForPublish(Form form, FormVersion row) {
FormSchemaValidator.validateSchema(row.getSchemaJson());
if (form.getCurrentVersion() != null && !form.getCurrentVersion().equals(row.getVersion())) {
FormVersion published = requireVersionRow(form.getId(), form.getCurrentVersion());
FormSchemaValidator.assertFieldTypesStable(published.getSchemaJson(), row.getSchemaJson());
}
dictService.assertRefsResolvable(form.getSchId(), row.getSchemaJson());
}
/** 草稿直改:只动未发布行(V7 的另一半——已发布行没有写路径) */
private FormVersion updateDraft(Long formId, Integer draftVersion, String schemaJson, String changeLog) {
FormVersion draft = requireVersionRow(formId, draftVersion);
if (draft.getPublishTime() != null) {
throw new BizException(ErrorCode.CUSTOM, "版本 " + draft.getVersion() + " 已发布,不可就地修改;请升版");
}
if (schemaJson != null) {
draft.setSchemaJson(schemaJson);
}
if (changeLog != null && !changeLog.trim().isEmpty()) {
draft.setChangeLog(trimLog(changeLog));
}
return versionRepository.save(draft);
}
/** code 变更:仅限从未发布的表单(契约 §1),并同步草稿行冗余列 */
private void changeCode(Form form, String newCode) {
if (form.getCurrentVersion() != null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"code 发布后不可改(改=换表单,契约 §1):" + form.getCode());
}
requireCode(newCode);
assertCodeAvailable(form.getSchId(), form.getAppCode(), newCode, form.getUuid());
form.setCode(newCode);
formRepository.save(form);
versionRepository.updateCodeOnDrafts(form.getId(), newCode);
}
/** 取副本源行:草稿优先(设计器"当前编辑内容"),否则当前发布版,否则最新版本行 */
private FormVersion copySourceVersion(Form source) {
FormVersion draft = versionRepository
.findFirstByFormIdAndPublishTimeIsNullOrderByVersionDesc(source.getId()).orElse(null);
if (draft != null) {
return draft;
}
if (source.getCurrentVersion() != null) {
return requireVersionRow(source.getId(), source.getCurrentVersion());
}
List<FormVersion> rows = versionRepository.findByFormIdOrderByVersionDesc(source.getId());
if (rows.isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "源表单没有可复制的版本行");
}
return rows.get(0);
}
private void assertCodeAvailable(Long schId, String appCode, String code, String selfUuid) {
Form existing = formRepository.findBySchIdAndAppCodeAndCode(schId, appCode, code).orElse(null);
if (existing == null) {
return;
}
if (selfUuid != null && selfUuid.equals(existing.getUuid())) {
return;
}
throw new BizException(ErrorCode.PARAM_ERROR,
Integer.valueOf(1).equals(existing.getIsDeleted())
? "同 code 的表单已存在(软删行仍占用该 code):" + appCode + "/" + code + ",请另起 code"
: "同校同应用下 code 已存在:" + appCode + "/" + code);
}
private Form requireForm(Long schId, String uuid) {
if (uuid == null || uuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "uuid 不能为空");
}
Form form = formRepository.findByUuidAndSchId(uuid.trim(), schId).orElse(null);
if (form == null || Integer.valueOf(1).equals(form.getIsDeleted())) {
// 不区分"不存在"与"别校的"——uuid 不可枚举,差别信息本身就是探测面
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + uuid);
}
return form;
}
private FormVersion requireVersionRow(Long formId, Integer version) {
return versionRepository.findByFormIdAndVersion(formId, version)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "版本不存在:" + version));
}
private FormVersion newVersionRow(Form form, int version, String schemaJson, String changeLog) {
FormVersion row = new FormVersion();
row.setFormId(form.getId());
row.setSchId(form.getSchId());
row.setAppCode(form.getAppCode());
row.setCode(form.getCode());
row.setVersion(version);
row.setSchemaJson(schemaJson);
row.setChangeLog(changeLog);
return row;
}
private String requireCode(String code) {
String clean = code == null ? null : code.trim();
if (clean == null || !CODE_PATTERN.matcher(clean).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"code 需为人可读串(字母/数字/下划线/连字符,≤64):" + code);
}
return clean;
}
private String requireName(String name) {
String clean = name == null ? null : name.trim();
if (clean == null || clean.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单名称不能为空");
}
if (clean.length() > 128) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单名称过长(≤128)");
}
return clean;
}
/** 归属 app_code:缺省 form-center(手册 §1),显式传入时只校验形态(快应用 app_code 由 UserBase 定) */
private String normalizeAppCode(String appCode) {
if (appCode == null || appCode.trim().isEmpty()) {
return Form.CENTER_APP_CODE;
}
String clean = appCode.trim();
if (clean.length() > 64) {
throw new BizException(ErrorCode.PARAM_ERROR, "appCode 过长(≤64):" + clean);
}
return clean;
}
private static String newUuid() {
return UUID.randomUUID().toString().replace("-", "").toLowerCase();
}
private static String trimLog(String changeLog) {
if (changeLog == null || changeLog.trim().isEmpty()) {
return null;
}
String clean = changeLog.trim();
return clean.length() > 500 ? clean.substring(0, 500) : clean;
}
private Integer draftVersion(Long formId) {
return versionRepository.findFirstByFormIdAndPublishTimeIsNullOrderByVersionDesc(formId)
.map(FormVersion::getVersion).orElse(null);
}
private Map<Long, Integer> draftVersions(List<Form> forms) {
Map<Long, Integer> result = new HashMap<>();
if (forms.isEmpty()) {
return result;
}
List<Long> ids = new ArrayList<>();
for (Form form : forms) {
ids.add(form.getId());
}
for (FormVersion row : versionRepository.findByFormIdInAndPublishTimeIsNull(ids)) {
result.put(row.getFormId(), row.getVersion());
}
return result;
}
/** 解析域(契约 §1 校域规则):会话校 ∪ 平台级(0);会话校=0 时只有一层 */
private static List<Long> resolveDomain(Long schId) {
return schId == null || schId == Form.PLATFORM_SCH_ID
? Collections.singletonList(Form.PLATFORM_SCH_ID) : Arrays.asList(schId, Form.PLATFORM_SCH_ID);
}
/** 遮蔽规则(U5):同 (app_code, code) 本校与平台级都在时,本校胜出且不回落——
* 本校行哪怕处于草稿/停用,也不让请求穿透到平台级(否则"本校遮蔽"会变成可绕过的建议)。
* 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。 */
private Form pickByShadowing(List<Form> candidates, Long schId) {
Form platformFallback = null;
for (Form form : candidates) {
if (form.getSchId() == null) {
continue;
}
if (form.getSchId().equals(schId)) {
return form;
}
if (form.getSchId() == Form.PLATFORM_SCH_ID) {
platformFallback = form;
}
}
return platformFallback;
}
private FormModels.FormView toView(Form form, Integer draftVersion) {
FormModels.FormView view = new FormModels.FormView();
view.setFormUuid(form.getUuid());
view.setSchId(form.getSchId());
view.setAppCode(form.getAppCode());
view.setCode(form.getCode());
view.setName(form.getName());
view.setStatus(form.getStatus());
view.setStatusName(FormEnums.FormStatus.nameOf(form.getStatus()));
view.setCurrentVersion(form.getCurrentVersion());
view.setDraftVersion(draftVersion);
view.setAudience(form.getAudience());
view.setFillLimit(form.getFillLimit());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
view.setAnswerNoEnabled(form.getAnswerNoEnabled());
view.setGroupTag(form.getGroupTag());
view.setStatusDefs(toStatusDefViews(form.getStatusDefs()));
view.setCreateTime(form.getCreateTime() == null ? null : TS.format(form.getCreateTime()));
view.setUpdateTime(form.getUpdateTime() == null ? null : TS.format(form.getUpdateTime()));
return view;
}
private FormModels.VersionView toVersionView(Form form, FormVersion row, boolean withSchema) {
FormModels.VersionView view = new FormModels.VersionView();
view.setFormUuid(form.getUuid());
view.setVersion(row.getVersion());
view.setPublished(row.getPublishTime() != null);
view.setChangeLog(row.getChangeLog());
view.setPublishTime(row.getPublishTime() == null ? null : TS.format(row.getPublishTime()));
view.setCreateTime(row.getCreateTime() == null ? null : TS.format(row.getCreateTime()));
view.setUpdateTime(row.getUpdateTime() == null ? null : TS.format(row.getUpdateTime()));
view.setSchemaJson(withSchema ? row.getSchemaJson() : null);
return view;
}
private FormModels.ResolvedFormView toResolvedView(Form form, FormVersion row, String expandedSchema) {
FormModels.ResolvedFormView view = new FormModels.ResolvedFormView();
view.setFormUuid(form.getUuid());
view.setCode(form.getCode());
view.setAppCode(form.getAppCode());
view.setName(form.getName());
view.setVersion(row.getVersion());
view.setAudience(form.getAudience());
view.setFillLimit(form.getFillLimit());
view.setAnswerNoEnabled(form.getAnswerNoEnabled());
view.setDeadline(form.getDeadline() == null ? null : TS.format(form.getDeadline()));
view.setDeadlineReached(FormRuntimeRules.isDeadlineReached(form, null));
view.setStatusDefs(toStatusDefViews(form.getStatusDefs()));
JsonNode groups = FormSchemaValidator.parse(expandedSchema, "表单结构损坏(请联系管理员)").path("groups");
view.setGroups(groups);
return view;
}
private List<FormModels.StatusDefView> toStatusDefViews(String statusDefsJson) {
List<FormModels.StatusDefView> views = new ArrayList<>();
for (FormStatusDefs.StatusDef def : FormStatusDefs.effective(statusDefsJson)) {
FormModels.StatusDefView view = new FormModels.StatusDefView();
view.setCode(def.getCode());
view.setLabel(def.getLabel());
view.setDeleted(def.isDeleted());
views.add(view);
}
return views;
}
}
@@ -0,0 +1,334 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.entity.FormDict;
import com.yunhai.formcenter.entity.FormDictItem;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.FormDictItemRepository;
import com.yunhai.formcenter.repository.FormDictRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 选项字典(契约 §2.1 / 方案 §4.3③b)。
*
* <p>三条不变式,全部是"字典机制能安全存在"的前提:</p>
* <ol>
* <li><b>value 不可变、label 可改</b>:答案存 value,字典改名不打穿历史答卷(V38/D11 同款);
* 实现上 value 即条目的定位键(uk(dict_id,value)),列映射 {@code updatable=false} 物理挡改写。</li>
* <li><b>软删条目的 value 仍通过提交校验</b>({@link #allowedValues})——旧答卷可原样重存;
* 仅设计器不可新选({@link #inlineOptions} 只出未删条目)。</li>
* <li><b>活引用、不进版本快照</b>:{@code form_version.schema_json} 里存的是
* {@code {"source":"dict:{code}"}},只在读路径展开({@link #expandSchema})。
* 字典本身不进对外契约,消费方不需要二次请求字典接口。</li>
* </ol>
*
* <p>两级读写口径必须分开:<b>管理</b>按同层精确匹配(校级改不到平台级、平台级不归学校管,
* 方案 §6.5);<b>解析</b>按域 {@code sch_id ∈ {会话校, 0}} 且本校遮蔽平台(契约 §2.1)。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FormDictService {
/** 字典 code 字符集:与 schema 里 {@code dict:{code}} 的解析式同源(FormSchemaValidator.DICT_SOURCE_PATTERN) */
private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,64}$");
private final FormDictRepository dictRepository;
private final FormDictItemRepository itemRepository;
// ==================== 管理面(同层精确匹配) ====================
/** 新建或改名。code 是 schema 的引用键 → 建后不可改;已软删的同 code 请求视为恢复。 */
@Transactional
public FormModels.DictView saveDict(Long schId, String code, String name) {
String cleanCode = code == null ? null : code.trim();
if (cleanCode == null || !CODE_PATTERN.matcher(cleanCode).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典 code 仅限字母/数字/下划线(≤64 位):" + code);
}
String cleanName = requireName(name);
FormDict dict = dictRepository.findBySchIdAndCode(schId, cleanCode).orElse(null);
if (dict == null) {
dict = new FormDict();
dict.setSchId(schId);
dict.setCode(cleanCode);
} else if (Integer.valueOf(1).equals(dict.getIsDeleted())) {
// 软删行仍占着 uk_dict(sch_id,code):新建同 code 必撞键,恢复是唯一不欺骗操作者的出路
dict.setIsDeleted(0);
}
dict.setName(cleanName);
try {
dict = dictRepository.save(dict);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典已存在(" + cleanCode + "),请刷新后重试");
}
return toView(dict, itemRepository.findByDictIdOrderBySortAscIdAsc(dict.getId()));
}
/** 软删字典:历史 schema 的引用在 {@link #expandSchema} 里降级为空 options,不炸读路径 */
@Transactional
public void deleteDict(Long schId, String code) {
FormDict dict = requireManagedDict(schId, code);
dict.setIsDeleted(1);
dictRepository.save(dict);
}
/** 本层字典清单(管理 tab):只出本校/本层自己的,不含平台级 */
public List<FormModels.DictView> listOwn(Long schId) {
List<FormModels.DictView> result = new ArrayList<>();
for (FormDict dict : dictRepository.findBySchIdAndIsDeletedOrderByCodeAsc(schId, 0)) {
result.add(toView(dict, itemRepository.findByDictIdOrderBySortAscIdAsc(dict.getId())));
}
return result;
}
/**
* 设计器可选字典(契约 §2.1:平台级学校"可见可用不可改"):
* 取域={@code 会话校 ∪ 平台级},同 code 本校遮蔽平台,只出未删字典与未删条目。
*/
public List<FormModels.DictView> listSelectable(Long schId) {
Map<String, FormDict> byCode = new LinkedHashMap<>();
for (FormDict dict : dictRepository.findBySchIdInAndIsDeletedOrderByCodeAsc(resolveDomain(schId), 0)) {
// 已按 code 升序,同 code 时先出现的层级胜出:平台级(0)排序在前,故显式让本校覆盖它
FormDict held = byCode.get(dict.getCode());
if (held == null || (dict.getSchId() != null && dict.getSchId().equals(schId))) {
byCode.put(dict.getCode(), dict);
}
}
List<FormModels.DictView> result = new ArrayList<>();
for (FormDict dict : byCode.values()) {
// 设计器候选=未删条目(软删条目不可新选,契约 §2.1)
result.add(toView(dict,
itemRepository.findByDictIdAndIsDeletedOrderBySortAscIdAsc(dict.getId(), 0)));
}
return result;
}
/** 新建条目(value 建一次即不可变)。同 value 已存在 → 只改 label/sort,不改 value。 */
@Transactional
public FormModels.DictItemView saveItem(Long schId, String dictCode, String value, String label, Integer sort) {
FormDict dict = requireManagedDict(schId, dictCode);
String cleanValue = value == null ? null : value.trim();
if (cleanValue == null || cleanValue.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典条目 value 不能为空");
}
if (cleanValue.length() > FormSchemaValidator.MAX_OPTION_VALUE_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字典条目 value 过长(≤" + FormSchemaValidator.MAX_OPTION_VALUE_LEN + "):" + cleanValue);
}
String cleanLabel = requireName(label, "字典条目 label");
FormDictItem item = itemRepository.findByDictIdAndValue(dict.getId(), cleanValue).orElse(null);
if (item == null) {
item = new FormDictItem();
item.setDictId(dict.getId());
item.setValue(cleanValue);
} else {
// value 是定位键,接口上没有"改 value"的通道;软删条目被重新编辑=放回可选集
item.setIsDeleted(0);
}
item.setLabel(cleanLabel);
item.setSort(sort == null ? (item.getSort() == null ? 0 : item.getSort()) : sort);
try {
item = itemRepository.save(item);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "条目已存在(" + cleanValue + "),请刷新后重试");
}
return toItemView(item);
}
/** 软删条目:设计器不再可选,<b>提交校验仍放行</b>(契约 §2.1) */
@Transactional
public void deleteItem(Long schId, String dictCode, String value) {
FormDict dict = requireManagedDict(schId, dictCode);
FormDictItem item = itemRepository.findByDictIdAndValue(dict.getId(),
value == null ? "" : value.trim()).orElse(null);
if (item == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典条目不存在:" + dictCode + "/" + value);
}
item.setIsDeleted(1);
itemRepository.save(item);
}
// ==================== 解析面(域=会话校 ∪ 平台级,本校遮蔽) ====================
/**
* 发布前的可解析性检查:每个 {@code dict:{code}} 引用必须能解析到未删字典,
* 且该字典至少有一个未删条目(否则填报页无可选项)。
*/
public void assertRefsResolvable(Long schId, String schemaJson) {
for (String code : FormSchemaValidator.dictRefCodes(schemaJson)) {
FormDict dict = resolveDict(schId, code);
if (dict == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字典引用无法解析:dict:" + code + "(不存在或已删除,或不属于本校/平台级)");
}
if (itemRepository.findByDictIdAndIsDeletedOrderBySortAscIdAsc(dict.getId(), 0).isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "字典 " + code + " 没有可用条目,不能发布");
}
}
}
/**
* 读路径内联展开(契约 §2.1"对外 schema 读接口下发解析后的内联 options"):
* 把 {@code options:{source:"dict:x"}} 换成 {@code options:[{label,value},…]}。
* 字典缺失/已删 → 展开为空数组并 warn(降级但不静默丢字段)。
*/
public String expandSchema(Long schId, String schemaJson) {
List<String> codes = FormSchemaValidator.dictRefCodes(schemaJson);
if (codes.isEmpty()) {
return schemaJson;
}
ObjectMapper mapper = FormSchemaValidator.mapper();
JsonNode root = FormSchemaValidator.parse(schemaJson, "表单结构损坏(请联系管理员)");
for (JsonNode group : root.path("groups")) {
for (JsonNode field : group.path("fields")) {
if (!field.isObject()) {
continue;
}
String code = FormSchemaValidator.dictRefCode(field);
if (code == null) {
continue;
}
ObjectNode fieldNode = (ObjectNode) field;
fieldNode.set("options", inlineOptions(schId, code, mapper));
}
}
try {
return mapper.writeValueAsString(root);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "schema 展开失败");
}
}
/** 设计器/填报页可选条目(未删,按 sort 升序、同值按 id 稳定) */
public ArrayNode inlineOptions(Long schId, String dictCode) {
return inlineOptions(schId, dictCode, FormSchemaValidator.mapper());
}
private ArrayNode inlineOptions(Long schId, String dictCode, ObjectMapper mapper) {
ArrayNode array = mapper.createArrayNode();
FormDict dict = resolveDict(schId, dictCode);
if (dict == null) {
log.warn("[字典展开] schId={} dict={} 不存在或已删除,options 降级为空数组", schId, dictCode);
return array;
}
for (FormDictItem item : itemRepository.findByDictIdAndIsDeletedOrderBySortAscIdAsc(dict.getId(), 0)) {
ObjectNode node = mapper.createObjectNode();
node.put("label", item.getLabel());
node.put("value", item.getValue());
array.add(node);
}
return array;
}
/**
* 提交校验放行集(T3 用):<b>含软删条目</b>的 value 全集——旧答卷原样重存的依据(V38)。
* 字典解析不到时返回空集(调用方据此拒绝,而不是放行任意值)。
*/
public Set<String> allowedValues(Long schId, String dictCode) {
Set<String> values = new LinkedHashSet<>();
FormDict dict = resolveDict(schId, dictCode);
if (dict == null) {
return values;
}
for (FormDictItem item : itemRepository.findByDictIdOrderBySortAscIdAsc(dict.getId())) {
values.add(item.getValue());
}
return values;
}
/** 遮蔽规则:同 code 两行都在时本校胜出(会话校=0 时只有平台级一层);兜底只认 sch_id=0 */
private FormDict resolveDict(Long schId, String code) {
List<FormDict> candidates = dictRepository.findResolveCandidates(code == null ? "" : code.trim(),
resolveDomain(schId));
FormDict platformFallback = null;
for (FormDict dict : candidates) {
if (dict.getSchId() == null) {
continue;
}
if (dict.getSchId().equals(schId)) {
return dict;
}
if (dict.getSchId() == Form.PLATFORM_SCH_ID) {
platformFallback = dict;
}
}
return platformFallback;
}
/** 解析域(契约 §1/§2.1 同款校域规则):会话校 ∪ 平台级(0);会话校本身就是 0 时只有一层 */
private List<Long> resolveDomain(Long schId) {
return schId == null || schId == Form.PLATFORM_SCH_ID
? java.util.Collections.singletonList(Form.PLATFORM_SCH_ID)
: Arrays.asList(schId, Form.PLATFORM_SCH_ID);
}
/** 管理面定位:同层精确匹配(校级管不到平台级,方案 §6.5) */
private FormDict requireManagedDict(Long schId, String code) {
FormDict dict = dictRepository.findBySchIdAndCode(schId, code == null ? "" : code.trim())
.orElse(null);
if (dict == null || Integer.valueOf(1).equals(dict.getIsDeleted())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字典不存在或已删除(sch_id=" + schId + "):" + code);
}
return dict;
}
private String requireName(String name) {
return requireName(name, "字典 name");
}
private String requireName(String name, String what) {
String clean = name == null ? null : name.trim();
if (clean == null || clean.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, what + " 不能为空");
}
if (clean.length() > 128) {
throw new BizException(ErrorCode.PARAM_ERROR, what + " 过长(≤128)");
}
return clean;
}
/** 字典视图组装:条目集合由调用方给定(管理 tab=全部含软删;设计器候选=未删) */
private FormModels.DictView toView(FormDict dict, List<FormDictItem> items) {
FormModels.DictView view = new FormModels.DictView();
view.setSchId(dict.getSchId());
view.setPlatformLevel(dict.getSchId() != null && dict.getSchId() == Form.PLATFORM_SCH_ID);
view.setCode(dict.getCode());
view.setName(dict.getName());
List<FormModels.DictItemView> views = new ArrayList<>();
for (FormDictItem item : items) {
views.add(toItemView(item));
}
view.setItems(views);
return view;
}
private FormModels.DictItemView toItemView(FormDictItem item) {
FormModels.DictItemView view = new FormModels.DictItemView();
view.setValue(item.getValue());
view.setLabel(item.getLabel());
view.setSort(item.getSort());
view.setDeleted(Integer.valueOf(1).equals(item.getIsDeleted()));
return view;
}
}
@@ -0,0 +1,89 @@
package com.yunhai.formcenter.common;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 表单级填报约束规则单测(方案 §5.4:截止 datetime 粒度 + 只选日期默认 23:59:59;
* limitScope 推导=subject 优先,否则 submitter)。T3 的限次/截止拒绝直接用这一份口径。
*/
@DisplayName("FormRuntimeRules - 截止与限次(方案 §5.4)")
class FormRuntimeRulesTest {
@Test
@DisplayName("只给日期→补当日 23:59:59;给到秒→原样;空/null→不限")
void deadlineParsing() {
assertThat(FormRuntimeRules.parseDeadline("2026-10-20"))
.isEqualTo(LocalDateTime.of(2026, 10, 20, 23, 59, 59));
assertThat(FormRuntimeRules.parseDeadline("2026-10-20 08:30:00"))
.isEqualTo(LocalDateTime.of(2026, 10, 20, 8, 30, 0));
assertThat(FormRuntimeRules.parseDeadline("")).isNull();
assertThat(FormRuntimeRules.parseDeadline(null)).isNull();
}
@Test
@DisplayName("非约定格式直接拒(不留 2026/10/1 或 10-20 这类歧义值进库)")
void badDeadlineFormatsRejected() {
for (String raw : new String[]{"2026/10/20", "10-20", "2026-10-20 8:30", "2026-13-40"}) {
assertThatThrownBy(() -> FormRuntimeRules.parseDeadline(raw))
.as(raw)
.isInstanceOf(BizException.class)
.hasMessageContaining("截止时间格式");
}
}
@Test
@DisplayName("过期判定按严格晚于:23:59:59 当秒仍可提交,过一秒即拒")
void deadlineBoundary() {
Form form = new Form();
form.setDeadline(LocalDateTime.of(2026, 10, 20, 23, 59, 59));
assertThat(FormRuntimeRules.isDeadlineReached(form, LocalDateTime.of(2026, 10, 20, 23, 59, 59))).isFalse();
assertThat(FormRuntimeRules.isDeadlineReached(form, LocalDateTime.of(2026, 10, 20, 23, 59, 58))).isFalse();
assertThat(FormRuntimeRules.isDeadlineReached(form, form.getDeadline().plusSeconds(1))).isTrue();
Form noDeadline = new Form();
assertThat(FormRuntimeRules.isDeadlineReached(noDeadline, LocalDateTime.now())).isFalse();
}
@Test
@DisplayName("limitScope:subject 非空按 subject 限,否则按 submitter 限;未开限次则拒绝推导")
void limitScopeDerivation() {
Form once = new Form();
once.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
assertThat(FormRuntimeRules.limitScope(once, 20260001)).isEqualTo(FormEnums.LimitScope.SUBJECT);
assertThat(FormRuntimeRules.limitScope(once, null)).isEqualTo(FormEnums.LimitScope.SUBMITTER);
Form unlimited = new Form();
unlimited.setFillLimit(FormEnums.FillLimit.UNLIMITED.getCode());
assertThatThrownBy(() -> FormRuntimeRules.limitScope(unlimited, null))
.isInstanceOf(BizException.class).hasMessageContaining("未开启限填一次");
}
@Test
@DisplayName("fill_quota.limit_key = coalesce(subject_id, submitter_id) 字符串化(契约 §10)")
void quotaKeyPrefersSubject() {
assertThat(FormRuntimeRules.quotaKey(20260001, 10133)).isEqualTo("20260001");
assertThat(FormRuntimeRules.quotaKey(null, 10133)).isEqualTo("10133");
assertThatThrownBy(() -> FormRuntimeRules.quotaKey(null, null))
.isInstanceOf(BizException.class);
}
@Test
@DisplayName("生命周期只有「已发布」开放填报入口(草稿/停用都不行)")
void onlyPublishedIsOpen() {
Form form = new Form();
form.setStatus(FormEnums.FormStatus.DRAFT.getCode());
assertThat(FormRuntimeRules.isFillOpen(form)).isFalse();
form.setStatus(FormEnums.FormStatus.DISABLED.getCode());
assertThat(FormRuntimeRules.isFillOpen(form)).isFalse();
form.setStatus(FormEnums.FormStatus.PUBLISHED.getCode());
assertThat(FormRuntimeRules.isFillOpen(form)).isTrue();
}
}
@@ -0,0 +1,310 @@
package com.yunhai.formcenter.common;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 契约 §2 schema 校验单测(自验项 <b>V2</b>:options 非 {label,value} 被拒;
* <b>V3</b>:白名单外属性被拒、未声明 searchable 的字段不进筛选候选)。
* 脱库脱网可跑——校验器是纯静态的,字典可解析性在 {@code FormDictServiceIT} 里验。
*/
@DisplayName("FormSchemaValidator - 契约 §2 schema 结构")
class FormSchemaValidatorTest {
/** 契约 §3 示例值的完整形态:19 项白名单里用得上的都在 */
private static final String OK_SCHEMA = "{\"groups\":["
+ "{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20},"
+ "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true},"
+ "{\"key\":\"score\",\"label\":\"综合测评分数\",\"type\":\"number\",\"precision\":1,\"unit\":\"分\"},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
+ "\"options\":[{\"label\":\"高二年级\",\"value\":\"g2\"},{\"label\":\"高三年级\",\"value\":\"g3\"}]},"
+ "{\"key\":\"subjects\",\"label\":\"获奖学科\",\"type\":\"checkbox\","
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]}"
+ "]},"
+ "{\"key\":\"files\",\"label\":\"附件\",\"fields\":["
+ "{\"key\":\"reason\",\"label\":\"申报理由\",\"type\":\"textarea\",\"maxLength\":1000},"
+ "{\"key\":\"mobile\",\"label\":\"手机号\",\"type\":\"phone\",\"pattern\":\"^1[3-9]\\\\d{9}$\"},"
+ "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"image\",\"accept\":\".jpg,.png\","
+ "\"maxSize\":10,\"maxFiles\":9,\"searchable\":false}"
+ "]}]}";
/** 只给 searchable 的字段建一份,用于筛选候选断言 */
private static final String SEARCHABLE_SCHEMA = "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
+ "{\"key\":\"a\",\"label\":\"A\",\"type\":\"text\",\"searchable\":true},"
+ "{\"key\":\"b\",\"label\":\"B\",\"type\":\"text\"},"
+ "{\"key\":\"c\",\"label\":\"C\",\"type\":\"select\",\"searchable\":true,"
+ "\"options\":[{\"label\":\"甲\",\"value\":\"1\"}]},"
+ "{\"key\":\"d\",\"label\":\"D\",\"type\":\"number\",\"searchable\":false}"
+ "]}]}";
private static String singleField(String fieldJson) {
return "{\"groups\":[{\"key\":\"g1\",\"label\":\"分组\",\"fields\":[" + fieldJson + "]}]}";
}
// ==================== V2:options 形状 ====================
@Test
@DisplayName("合法 schema(含 precision/unit/searchable/{label,value} 选项)通过并返回字段数")
void validSchemaPasses() {
assertThat(FormSchemaValidator.validateSchema(OK_SCHEMA)).isEqualTo(8);
}
@Test
@DisplayName("V2:options 写成字符串数组被拒(svc-hall 旧形态,label 即 value 会打穿历史统计)")
void stringArrayOptionsRejected() {
for (String type : Arrays.asList("select", "radio", "checkbox", "multiselect")) {
String schema = singleField("{\"key\":\"g\",\"label\":\"年级\",\"type\":\"" + type
+ "\",\"options\":[\"高二年级\",\"高三年级\"]}");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(schema))
.as(type + " 的字符串数组 options")
.isInstanceOf(BizException.class)
.hasMessageContaining("字符串数组被拒");
}
}
@Test
@DisplayName("options 条目缺 value / value 重复被拒(value 是答案存储值,必须齐备且唯一)")
void optionValueMustBePresentAndUnique() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"g\",\"label\":\"年级\",\"type\":\"select\",\"options\":[{\"label\":\"高二\"}]}")))
.hasMessageContaining("缺少 value");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"g\",\"label\":\"年级\",\"type\":\"select\",\"options\":["
+ "{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高二上\",\"value\":\"g2\"}]}")))
.hasMessageContaining("value 重复");
}
@Test
@DisplayName("options 条目带契约外键(如 disabled)被拒")
void optionExtraPropRejected() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"g\",\"label\":\"年级\",\"type\":\"select\",\"options\":["
+ "{\"label\":\"高二\",\"value\":\"g2\",\"disabled\":true}]}")))
.hasMessageContaining("disabled");
}
@Test
@DisplayName("选项控件必须有候选:options 缺失或空数组都拒")
void optionTypesRequireOptions() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"g\",\"label\":\"年级\",\"type\":\"select\"}")))
.isInstanceOf(BizException.class).hasMessageContaining("必须提供 options");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"g\",\"label\":\"年级\",\"type\":\"multiselect\",\"options\":[]}")))
.hasMessageContaining("不能为空数组");
}
// ==================== 字典引用(契约 §2.1) ====================
@Test
@DisplayName("字典引用形态 options:{\"source\":\"dict:{code}\"} 通过,并能被解析出引用清单")
void dictSourceOptionsAcceptedAndCollected() {
String schema = singleField("{\"key\":\"nation\",\"label\":\"民族\",\"type\":\"select\","
+ "\"options\":{\"source\":\"dict:nation\"}}");
assertThat(FormSchemaValidator.validateSchema(schema)).isEqualTo(1);
assertThat(FormSchemaValidator.dictRefCodes(schema)).containsExactly("nation");
}
@Test
@DisplayName("source 形态不对(缺 dict: 前缀/非法 code/附带别的键)都拒——不留半成品引用语义")
void badDictSourceRejected() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"民族\",\"type\":\"select\",\"options\":{\"source\":\"nation\"}}")))
.hasMessageContaining("dict:");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"民族\",\"type\":\"select\",\"options\":{\"source\":\"dict:民族\"}}")))
.hasMessageContaining("dict:");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"民族\",\"type\":\"select\",\"options\":"
+ "{\"source\":\"dict:nation\",\"inline\":[{\"label\":\"汉\",\"value\":\"h\"}]}}")))
.hasMessageContaining("inline");
}
// ==================== V3:属性白名单 ====================
@Test
@DisplayName("V3:白名单外属性一律被拒(orderNum/自定义校验器/读接口生成的 valueSchema 都不进契约)")
void propsOutsideWhitelistRejected() {
for (String extra : Arrays.asList("\"orderNum\":1", "\"dataValidator\":\"return true\"",
"\"valueSchema\":{\"type\":\"string\"}", "\"required2\":true", "\"options2\":[]")) {
String schema = singleField("{\"key\":\"x\",\"label\":\"X\",\"type\":\"text\"," + extra + "}");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(schema))
.as("白名单外属性 " + extra)
.isInstanceOf(BizException.class)
.hasMessageContaining("白名单");
}
}
@Test
@DisplayName("白名单共 19 项:16 项同源 + precision/unit/searchable")
void whitelistIsTheFrozenNineteen() {
assertThat(FormSchemaValidator.ALLOWED_PROPS).hasSize(19)
.contains("precision", "unit", "searchable")
.doesNotContain("orderNum", "valueSchema", "fieldId");
}
@Test
@DisplayName("searchable 只认真布尔值(字符串 \"true\" 被拒,避免筛选候选随类型漂移)")
void searchableMustBeBoolean() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"x\",\"label\":\"X\",\"type\":\"text\",\"searchable\":\"true\"}")))
.hasMessageContaining("searchable");
assertThatCode(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"x\",\"label\":\"X\",\"type\":\"text\",\"searchable\":true}")))
.doesNotThrowAnyException();
}
@Test
@DisplayName("V3:只有显式声明 searchable 的字段进筛选候选,未声明与显式 false 都不进")
void searchableKeysOnlyDeclaredOnes() {
List<String> keys = FormSchemaValidator.searchableKeys(SEARCHABLE_SCHEMA);
assertThat(keys).containsExactly("a", "c");
assertThat(keys).doesNotContain("b", "d");
}
// ==================== 控件类型(14 冻结 / 一期 11) ====================
@Test
@DisplayName("14 类一次冻结、一期放行 11 类:address/user/users 明确拒绝并说明二期")
void phase2TypesRejected() {
assertThat(FormSchemaValidator.FROZEN_TYPES).hasSize(14);
assertThat(FormSchemaValidator.PHASE1_TYPES).hasSize(11);
for (String type : Arrays.asList("address", "user", "users")) {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"u\",\"label\":\"U\",\"type\":\"" + type + "\"}")))
.as(type)
.isInstanceOf(BizException.class)
.hasMessageContaining("二期开放,一期不可用");
}
}
@Test
@DisplayName("14 类之外的类型(richtext/…)被拒")
void unfrozenTypeRejected() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"x\",\"label\":\"X\",\"type\":\"richtext\"}")))
.hasMessageContaining("未冻结");
}
// ==================== 字段 key(契约 §2) ====================
@Test
@DisplayName("key 规则:字母/数字/下划线 ≤64、表单内唯一(中文/空格/超长都拒)")
void keyRules() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"姓 名\",\"label\":\"姓名\",\"type\":\"text\"}")))
.hasMessageContaining("字段 key");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"a-b\",\"label\":\"A\",\"type\":\"text\"}")))
.hasMessageContaining("字段 key");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
singleField("{\"key\":\"" + repeat('k', 65) + "\",\"label\":\"A\",\"type\":\"text\"}")))
.hasMessageContaining("字段 key");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema("{\"groups\":["
+ "{\"key\":\"g1\",\"label\":\"一组\",\"fields\":[{\"key\":\"a\",\"label\":\"A\",\"type\":\"text\"},"
+ "{\"key\":\"a\",\"label\":\"A2\",\"type\":\"text\"}]}]}"))
.hasMessageContaining("重复");
}
@Test
@DisplayName("分组结构:group 只允许 key/label/fields,label 必填")
void groupShapeRules() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
"{\"groups\":[{\"key\":\"g1\",\"label\":\"一组\",\"fields\":[],\"title\":\"旧字段\"}]}"))
.hasMessageContaining("分组属性");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
"{\"groups\":[{\"key\":\"g1\",\"fields\":[{\"key\":\"a\",\"label\":\"A\",\"type\":\"text\"}]}]}"))
.hasMessageContaining("分组缺少 label");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema("{\"groups\":[]}"))
.hasMessageContaining("分组");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(
"{\"groups\":[{\"key\":\"g1\",\"label\":\"一组\",\"fields\":[]}]}"))
.hasMessageContaining("至少");
}
// ==================== 跨版本字段身份(V7/V8 前提) ====================
@Test
@DisplayName("同 key 换 type 被拒(改 key=换字段;同 key 换类型会让历史索引语义错配)")
void sameKeyMustKeepTypeAcrossVersions() {
String v1 = singleField("{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\"}");
String v2 = singleField("{\"key\":\"score\",\"label\":\"分数\",\"type\":\"text\"}");
assertThatThrownBy(() -> FormSchemaValidator.assertFieldTypesStable(v1, v2))
.isInstanceOf(BizException.class)
.hasMessageContaining("type 不可变更");
}
@Test
@DisplayName("删字段与新增字段是正常升版路径(V8 历史仍可读、V9 零 DDL 加字段)")
void deleteAndAddFieldsAreFine() {
String v1 = "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
+ "{\"key\":\"a\",\"label\":\"A\",\"type\":\"text\"},"
+ "{\"key\":\"b\",\"label\":\"B\",\"type\":\"date\"}]}]}";
String v2 = "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
+ "{\"key\":\"a\",\"label\":\"A 改名\",\"type\":\"text\",\"searchable\":true},"
+ "{\"key\":\"c\",\"label\":\"C\",\"type\":\"select\",\"options\":[{\"label\":\"甲\",\"value\":\"1\"}]}]}]}";
assertThatCode(() -> FormSchemaValidator.assertFieldTypesStable(v1, v2)).doesNotThrowAnyException();
}
// ==================== 其它声明式约束 ====================
@Test
@DisplayName("precision 上界 6(对齐 answer_index.v_num DECIMAL(20,6));unit 必须非空字符串")
void precisionAndUnit() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"N\",\"type\":\"number\",\"precision\":7}")))
.hasMessageContaining("precision");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"N\",\"type\":\"number\",\"unit\":\" \"}")))
.hasMessageContaining("unit");
}
@Test
@DisplayName("defaultValue 的 JSON 类型须与契约 §3 值形状一致(number 给字符串、date 给非 YYYY-MM-DD 都拒)")
void defaultValueMustMatchValueShape() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"N\",\"type\":\"number\",\"defaultValue\":\"12\"}")))
.hasMessageContaining("defaultValue");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"d\",\"label\":\"D\",\"type\":\"date\",\"defaultValue\":\"2023-1-9\"}")))
.hasMessageContaining("YYYY-MM-DD");
assertThatCode(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"d\",\"label\":\"D\",\"type\":\"date\",\"defaultValue\":\"2023-01-09\"}")))
.doesNotThrowAnyException();
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"m\",\"label\":\"M\",\"type\":\"multiselect\",\"defaultValue\":\"math\","
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"}]}")))
.hasMessageContaining("必须是数组");
}
@Test
@DisplayName("min>max、maxLength 非正整数、pattern 不可编译都拒")
void rangeAndPatternGuards() {
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"n\",\"label\":\"N\",\"type\":\"number\",\"min\":10,\"max\":1}")))
.hasMessageContaining("min 不得大于 max");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"t\",\"label\":\"T\",\"type\":\"text\",\"maxLength\":0}")))
.hasMessageContaining("maxLength");
assertThatThrownBy(() -> FormSchemaValidator.validateSchema(singleField(
"{\"key\":\"t\",\"label\":\"T\",\"type\":\"text\",\"pattern\":\"[a-\"}")))
.hasMessageContaining("pattern 不可编译");
}
private static String repeat(char c, int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append(c);
}
return sb.toString();
}
}
@@ -0,0 +1,101 @@
package com.yunhai.formcenter.common;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 状态定义单测(自验项 <b>V12</b>:单表单可自定义状态集合,默认含"待处理/已处理")。
* 顺带钉住 V13 的存储前提:删状态=软删定义,定义行不物理删,历史 code 仍可回显名。
*/
@DisplayName("FormStatusDefs - 轻状态位(方案 §5.3)")
class FormStatusDefsTest {
@Test
@DisplayName("V12:未配置时用默认集合,且默认就是待处理/已处理")
void defaultSetIsPendingAndProcessed() {
List<FormStatusDefs.StatusDef> defs = FormStatusDefs.effective(null);
assertThat(defs).hasSize(2);
assertThat(defs.stream().map(FormStatusDefs.StatusDef::getLabel).collect(Collectors.toList()))
.containsExactly("待处理", "已处理");
assertThat(FormStatusDefs.effective(" ")).hasSize(2);
assertThat(FormStatusDefs.defaultStatusCode(null)).isEqualTo("pending");
}
@Test
@DisplayName("V12:自定义整集合(替换默认,顺序即展示顺序),存储形状是 [{code,label,is_deleted}]")
void customSetReplacesDefault() {
String stored = FormStatusDefs.normalize("[{\"code\":\"draft\",\"label\":\"初筛\"},"
+ "{\"code\":\"pass\",\"label\":\"通过\",\"is_deleted\":false},"
+ "{\"code\":\"back\",\"label\":\"退回\"}]");
assertThat(stored).contains("\"is_deleted\":false")
.doesNotContain("\"deleted\"");
List<FormStatusDefs.StatusDef> defs = FormStatusDefs.effective(stored);
assertThat(defs.stream().map(FormStatusDefs.StatusDef::getCode).collect(Collectors.toList()))
.containsExactly("draft", "pass", "back");
// 自定义后默认集合不再存在("允许单表单自定义全部状态")
assertThat(FormStatusDefs.contains(stored, "pending")).isFalse();
assertThat(FormStatusDefs.defaultStatusCode(stored)).isEqualTo("draft");
}
@Test
@DisplayName("软删定义:不在可选集里但仍可回显名(V13 的存储侧依据)")
void deletedDefStillResolvable() {
String stored = FormStatusDefs.normalize("[{\"code\":\"pending\",\"label\":\"待处理\"},"
+ "{\"code\":\"processed\",\"label\":\"已处理\",\"is_deleted\":true}]");
assertThat(FormStatusDefs.liveDefs(stored).stream()
.map(FormStatusDefs.StatusDef::getCode).collect(Collectors.toList()))
.containsExactly("pending");
assertThat(FormStatusDefs.labelOf(stored, "processed")).isEqualTo("已处理");
assertThat(FormStatusDefs.contains(stored, "processed")).isTrue();
assertThat(FormStatusDefs.defaultStatusCode(stored)).isEqualTo("pending");
}
@Test
@DisplayName("非法定义一律拒绝:空集合/未知属性/code 空或重复/label 空/is_deleted 非布尔")
void invalidDefsRejected() {
assertThatThrownBy(() -> FormStatusDefs.normalize("[]"))
.isInstanceOf(BizException.class).hasMessageContaining("非空 JSON 数组");
assertThatThrownBy(() -> FormStatusDefs.normalize("{ \"code\":\"a\" }"))
.hasMessageContaining("非空 JSON 数组");
assertThatThrownBy(() -> FormStatusDefs.normalize("[{\"code\":\"a\",\"label\":\"A\",\"color\":\"red\"}]"))
.hasMessageContaining("不在契约内");
assertThatThrownBy(() -> FormStatusDefs.normalize("[{\"label\":\"A\"}]"))
.hasMessageContaining("缺少 code");
assertThatThrownBy(() -> FormStatusDefs.normalize("[{\"code\":\"a\"}]"))
.hasMessageContaining("缺少 label");
assertThatThrownBy(() -> FormStatusDefs.normalize(
"[{\"code\":\"a\",\"label\":\"A\",\"is_deleted\":\"yes\"}]"))
.hasMessageContaining("is_deleted");
}
@Test
@DisplayName("已废弃的 code 不得被复用——复用会改写历史答卷的状态名")
void reuseOfDeletedCodeRejected() {
assertThatThrownBy(() -> FormStatusDefs.normalize(
"[{\"code\":\"pending\",\"label\":\"待处理\"},"
+ "{\"code\":\"pending\",\"label\":\"旧的已处理\",\"is_deleted\":true}]"))
.isInstanceOf(BizException.class).hasMessageContaining("重复");
}
@Test
@DisplayName("全部软删被拒:新数据无状态可给")
void allDeletedRejected() {
assertThatThrownBy(() -> FormStatusDefs.normalize(
"[{\"code\":\"a\",\"label\":\"A\",\"is_deleted\":true}]"))
.isInstanceOf(BizException.class).hasMessageContaining("至少要保留一个可用状态");
}
@Test
@DisplayName("空入参归一化为 null(=回到默认,与「配过又清空」区分开)")
void blankMeansDefault() {
assertThat(FormStatusDefs.normalize(null)).isNull();
assertThat(FormStatusDefs.normalize(" ")).isNull();
}
}
@@ -0,0 +1,210 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 表单复制=设计器"另存为副本"(自验项 <b>V41</b>,方案 §5.4 v1.3 定一期):
* 副本=新 code / 新 uuid / version 从 1 起、schema 深拷贝、字典引用随行;
* <b>原表单与其历史数据零改动</b>(本方法对源表只读,测试把源快照逐字段比回去)。
*/
@SpringBootTest
@Transactional
@DisplayName("FormDefService#copy - 另存为副本(V41)")
class FormCopyIT {
private static final long SCH = 990201L;
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"searchable\":true,"
+ "\"options\":{\"source\":\"dict:grade\"}},"
+ "{\"key\":\"reason\",\"label\":\"申报理由\",\"type\":\"textarea\",\"maxLength\":1000}]}]}";
@Autowired
private FormDefService defService;
@Autowired
private FormDictService dictService;
@Test
@DisplayName("V41:副本新 code/新 uuid/version 1/草稿态,schema 深拷贝且字典引用随行,源表单零改动")
void copyCreatesFreshIdentityAndKeepsSourceUntouched() {
dictService.saveDict(SCH, "grade", "年级");
dictService.saveItem(SCH, "grade", "g2", "高二", 1);
FormModels.FormView source = newSource("honor_apply", "荣誉申报");
defService.save(SCH, save(source.getFormUuid(), SCHEMA, null));
defService.publish(SCH, source.getFormUuid(), null);
FormModels.FormView before = defService.get(SCH, source.getFormUuid());
String beforeSchema = defService.version(SCH, before.getFormUuid(), 1).getSchemaJson();
List<FormModels.VersionView> beforeHistory = defService.versions(SCH, before.getFormUuid());
FormModels.FormCopyRequest req = new FormModels.FormCopyRequest();
req.setUuid(before.getFormUuid());
req.setCode("honor_apply_2027");
req.setName("荣誉申报 2027");
FormModels.FormView copy = defService.copy(SCH, req);
// 副本身份全新
assertThat(copy.getFormUuid()).isNotEqualTo(before.getFormUuid()).hasSize(32);
assertThat(copy.getCode()).isEqualTo("honor_apply_2027");
assertThat(copy.getCurrentVersion()).isNull();
assertThat(copy.getDraftVersion()).isEqualTo(1);
assertThat(copy.getStatusName()).isEqualTo("DRAFT");
assertThat(defService.versions(SCH, copy.getFormUuid())).hasSize(1);
assertThat(defService.version(SCH, copy.getFormUuid(), 1).getPublished()).isFalse();
// schema 深拷贝(内容等值但是另一份行),字典引用原样随行(活引用不物化)
String copiedSchema = defService.version(SCH, copy.getFormUuid(), 1).getSchemaJson();
assertThat(copiedSchema).isEqualTo(beforeSchema).contains("dict:grade");
// 副本未发布,resolve 不认它;源表单照常可解析
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "honor_apply_2027"))
.isInstanceOf(BizException.class).hasMessageContaining("尚未发布");
assertThat(defService.resolve(SCH, "form-center", "honor_apply").getFormUuid())
.isEqualTo(before.getFormUuid());
// 源表单零改动
FormModels.FormView after = defService.get(SCH, before.getFormUuid());
assertThat(after.getCode()).isEqualTo(before.getCode());
assertThat(after.getFormUuid()).isEqualTo(before.getFormUuid());
assertThat(after.getCurrentVersion()).isEqualTo(before.getCurrentVersion());
assertThat(after.getStatusName()).isEqualTo("PUBLISHED");
assertThat(after.getName()).isEqualTo(before.getName());
assertThat(after.getDraftVersion()).isEqualTo(before.getDraftVersion());
assertThat(defService.version(SCH, before.getFormUuid(), 1).getSchemaJson()).isEqualTo(beforeSchema);
assertThat(defService.versions(SCH, before.getFormUuid()))
.extracting(FormModels.VersionView::getVersion).isEqualTo(
beforeHistory.stream().map(FormModels.VersionView::getVersion)
.collect(java.util.stream.Collectors.toList()));
// 字典改 label 后,源与副本的读接口同时生效(共享同一活引用,不是各自一份快照)
dictService.saveItem(SCH, "grade", "g2", "高二年级", 1);
assertThat(defService.resolve(SCH, "form-center", "honor_apply").getGroups().toString())
.contains("高二年级");
}
@Test
@DisplayName("副本继承表单级配置但不继承收集窗口(旧截止日期会让副本一出生就只读)")
void copyInheritsConfigButNotDeadline() {
FormModels.FormView source = newSource("config_source", "带配置的表单");
defService.save(SCH, save(source.getFormUuid(), SCHEMA.replace("{\"source\":\"dict:grade\"}",
"[{\"label\":\"数学\",\"value\":\"math\"}]"), null));
defService.publish(SCH, source.getFormUuid(), null);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(source.getFormUuid());
config.setAudience(FormEnums.Audience.TEACHER.getCode());
config.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
config.setDeadline("2020-01-01");
config.setAnswerNoEnabled(1);
config.setGroupTag("campus");
defService.updateConfig(SCH, config);
FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest();
defs.setUuid(source.getFormUuid());
defs.setStatusDefs(java.util.Collections.singletonList(statusDef("first", "初筛")));
defService.updateStatusDefs(SCH, defs);
FormModels.FormCopyRequest req = new FormModels.FormCopyRequest();
req.setUuid(source.getFormUuid());
req.setCode("config_copy");
req.setName("带配置的表单 副本");
FormModels.FormView copy = defService.copy(SCH, req);
assertThat(copy.getAudience()).isEqualTo(FormEnums.Audience.TEACHER.getCode());
assertThat(copy.getFillLimit()).isEqualTo(FormEnums.FillLimit.ONCE.getCode());
assertThat(copy.getAnswerNoEnabled()).isEqualTo(1);
assertThat(copy.getGroupTag()).isEqualTo("campus");
assertThat(copy.getStatusDefs()).extracting(FormModels.StatusDefView::getCode).containsExactly("first");
assertThat(copy.getDeadline()).isNull();
assertThat(defService.get(SCH, source.getFormUuid()).getDeadline()).isEqualTo("2020-01-01 23:59:59");
}
@Test
@DisplayName("副本可以挂到别的 app_code(同校不同命名空间),code 撞车仍被拒")
void copyCanTargetAnotherAppCodeAndRejectsDuplicateCode() {
FormModels.FormView source = newSource("app_source", "跨应用复制");
defService.save(SCH, save(source.getFormUuid(),
SCHEMA.replace("{\"source\":\"dict:grade\"}", "[{\"label\":\"数学\",\"value\":\"math\"}]"), null));
FormModels.FormCopyRequest toQuick = new FormModels.FormCopyRequest();
toQuick.setUuid(source.getFormUuid());
toQuick.setAppCode("honor");
toQuick.setCode("app_copy");
toQuick.setName("跨应用复制 副本");
FormModels.FormView copied = defService.copy(SCH, toQuick);
assertThat(copied.getAppCode()).isEqualTo("honor");
FormModels.FormCopyRequest dup = new FormModels.FormCopyRequest();
dup.setUuid(source.getFormUuid());
dup.setAppCode("honor");
dup.setCode("app_copy");
dup.setName("重复 code 副本");
assertThatThrownBy(() -> defService.copy(SCH, dup))
.isInstanceOf(BizException.class).hasMessageContaining("code 已存在");
FormModels.FormCopyRequest sameAsSource = new FormModels.FormCopyRequest();
sameAsSource.setUuid(source.getFormUuid());
sameAsSource.setCode("app_source");
sameAsSource.setName("就地覆盖");
assertThatThrownBy(() -> defService.copy(SCH, sameAsSource))
.isInstanceOf(BizException.class).hasMessageContaining("code 已存在");
}
@Test
@DisplayName("副本入参同样受约束:code 非法/名为空/源不存在都拒")
void copyInputGuards() {
FormModels.FormView source = newSource("guard_source", "守卫源");
FormModels.FormCopyRequest req = new FormModels.FormCopyRequest();
req.setUuid(source.getFormUuid());
req.setCode("bad code");
req.setName("非法 code 副本");
assertThatThrownBy(() -> defService.copy(SCH, req)).hasMessageContaining("code 需为人可读串");
req.setCode("guard_copy");
req.setName(" ");
assertThatThrownBy(() -> defService.copy(SCH, req)).hasMessageContaining("表单名称不能为空");
req.setName("合法名");
req.setUuid("00000000000000000000000000000000");
assertThatThrownBy(() -> defService.copy(SCH, req)).hasMessageContaining("表单不存在");
}
// ==================== 辅助 ====================
private FormModels.FormView newSource(String code, String name) {
FormModels.FormCreateRequest req = new FormModels.FormCreateRequest();
req.setAppCode(Form.CENTER_APP_CODE);
req.setCode(code);
req.setName(name);
return defService.create(SCH, req);
}
private FormModels.FormSaveRequest save(String uuid, String schemaJson, String changeLog) {
FormModels.FormSaveRequest req = new FormModels.FormSaveRequest();
req.setUuid(uuid);
req.setSchemaJson(schemaJson);
req.setChangeLog(changeLog);
return req;
}
private FormModels.StatusDefView statusDef(String code, String label) {
FormModels.StatusDefView def = new FormModels.StatusDefView();
def.setCode(code);
def.setLabel(label);
return def;
}
}
@@ -0,0 +1,380 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 定义域版本语义集成测试(打<b>真实 MySQL</b>:本地 8.0.45 / 库 bsch_formcenter,
* {@code ddl-auto=update} 建表——CI 不跑单测,本地 {@code mvn test} 即门禁,任务书 §0.4)。
*
* <p>覆盖自验项 <b>V6</b>(同 sch_id 同 code、不同 app_code 共存不冲突)、
* <b>V7</b>(已发布版本无法就地修改;改版产生 version+1 新行;老版本 schema_json 逐字节不变),
* 以及契约 §1 的 uuid/resolve 校域遮蔽、方案 §5.4 的生命周期与"配置不进版本快照"。</p>
*/
@SpringBootTest
@Transactional
@DisplayName("FormDefService - 版本与生命周期(V6/V7)")
class FormDefServiceIT {
private static final long SCH = 990001L;
private static final long OTHER_SCH = 990002L;
private static final String SCHEMA_A = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"searchable\":true,"
+ "\"options\":[{\"label\":\"高二年级\",\"value\":\"g2\"}]}]}]}";
/** 升版:改名+加字段+给 name 开 searchable,但同 key 的 type 不动(V7/V8/V9 的正常路径) */
private static final String SCHEMA_B = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"学生姓名\",\"type\":\"text\",\"maxLength\":20,\"searchable\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"searchable\":true,"
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"unit\":\"分\"}]}]}";
@Autowired
private FormDefService defService;
@Autowired
private ObjectMapper objectMapper;
// ==================== V6 ====================
@Test
@DisplayName("V6:同校同 code、不同 app_code 的两条表单共存;同 app_code 重复才被拒")
void sameCodeUnderDifferentAppCodesCoexist() {
FormModels.FormView center = defService.create(SCH, create("form-center", "honor_apply"));
FormModels.FormView quick = defService.create(SCH, create("honor", "honor_apply"));
assertThat(center.getFormUuid()).isNotEqualTo(quick.getFormUuid());
assertThat(center.getAppCode()).isEqualTo("form-center");
assertThat(quick.getAppCode()).isEqualTo("honor");
assertThat(defService.list(SCH, "honor", null)).hasSize(1);
assertThat(defService.list(SCH, null, null)).hasSize(2);
assertThatThrownBy(() -> defService.create(SCH, create("form-center", "honor_apply")))
.isInstanceOf(BizException.class)
.hasMessageContaining("code 已存在");
}
@Test
@DisplayName("两校同 (app_code,code) 各自独立,互不可见(租户隔离)")
void twoSchoolsAreIndependentNamespaces() {
defService.create(SCH, create("form-center", "shared_code"));
FormModels.FormView other = defService.create(OTHER_SCH, create("form-center", "shared_code"));
assertThat(other.getSchId()).isEqualTo(OTHER_SCH);
assertThat(defService.list(SCH, null, null)).hasSize(1);
assertThatThrownBy(() -> defService.get(SCH, other.getFormUuid()))
.isInstanceOf(BizException.class).hasMessageContaining("表单不存在");
}
// ==================== V7 ====================
@Test
@DisplayName("V7:已发布行不可就地修改;改版=v+1 新行,老行 schema_json 逐字节不变;升版瞬间旧版仍可解析")
void publishedVersionIsImmutableAndUpgradeAddsNewRow() {
FormModels.FormView form = defService.create(SCH, create("form-center", "honor_apply"));
String uuid = form.getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, "首次发布");
FormModels.VersionView v1 = defService.version(SCH, uuid, 1);
assertThat(v1.getPublished()).isTrue();
assertThat(v1.getPublishTime()).isNotBlank();
assertThat(v1.getSchemaJson()).isEqualTo(SCHEMA_A);
// 保存新结构:开 v2 草稿行,头表指针与状态都不动(契约 §5 在途提交天然按旧版)
FormModels.FormView afterSave = defService.save(SCH, save(uuid, SCHEMA_B, "加分数列"));
assertThat(afterSave.getCurrentVersion()).isEqualTo(1);
assertThat(afterSave.getDraftVersion()).isEqualTo(2);
assertThat(afterSave.getStatusName()).isEqualTo("PUBLISHED");
// 老行逐字节不变(缓存声明的前提,方案 §3.2)
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).isEqualTo(SCHEMA_A);
assertThat(defService.version(SCH, uuid, 2).getSchemaJson()).isEqualTo(SCHEMA_B);
assertThat(defService.version(SCH, uuid, 2).getPublished()).isFalse();
// 再发布才推指针
FormModels.FormView published = defService.publish(SCH, uuid, null);
assertThat(published.getCurrentVersion()).isEqualTo(2);
assertThat(published.getDraftVersion()).isNull();
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).isEqualTo(SCHEMA_A);
List<FormModels.VersionView> history = defService.versions(SCH, uuid);
assertThat(history).extracting(FormModels.VersionView::getVersion).containsExactly(2, 1);
assertThat(history.get(0).getSchemaJson()).isNull();
}
@Test
@DisplayName("没有草稿时重复发布被拒(不给「就地改已发布行」留任何入口)")
void republishWithoutDraftIsRejected() {
String uuid = defService.create(SCH, create("form-center", "only_once")).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
assertThatThrownBy(() -> defService.publish(SCH, uuid, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("没有待发布的草稿版本");
}
@Test
@DisplayName("同 key 换 type 的升版被拒;改 label/加 searchable/增删字段放行(契约 §2 key 稳定性)")
void fieldIdentityIsStableAcrossVersions() {
String uuid = defService.create(SCH, create("form-center", "key_stability")).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
String changeType = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"number\"}]}]}";
assertThatThrownBy(() -> defService.save(SCH, save(uuid, changeType, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("type 不可变更");
}
@Test
@DisplayName("非法 schema 在保存阶段就被拦下,草稿行原样不动(不进库)")
void saveRejectsInvalidSchemaWithoutTouchingDraft() {
String uuid = defService.create(SCH, create("form-center", "bad_schema")).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
String stringArrayOptions = "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
+ "{\"key\":\"a\",\"label\":\"A\",\"type\":\"select\",\"options\":[\"甲\",\"乙\"]}]}]}";
assertThatThrownBy(() -> defService.save(SCH, save(uuid, stringArrayOptions, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("字符串数组被拒");
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).isEqualTo(SCHEMA_A);
String unknownProp = SCHEMA_A.replace("\"required\":true", "\"required\":true,\"orderNum\":1");
assertThatThrownBy(() -> defService.save(SCH, save(uuid, unknownProp, null)))
.isInstanceOf(BizException.class).hasMessageContaining("白名单");
}
// ==================== 标识 / uuid(契约 §1) ====================
@Test
@DisplayName("建草稿即生成 32 位 uuid,跨版本不变;响应 DTO 不含库内 id(V5 的响应面断言)")
void uuidGeneratedAtDraftAndIdsNotExposed() throws Exception {
FormModels.FormView created = defService.create(SCH, create("form-center", "uuid_check"));
assertThat(created.getFormUuid()).hasSize(32).matches("^[0-9a-f]{32}$");
defService.save(SCH, save(created.getFormUuid(), SCHEMA_A, null));
defService.publish(SCH, created.getFormUuid(), null);
defService.save(SCH, save(created.getFormUuid(), SCHEMA_B, "升版"));
FormModels.FormView upgraded = defService.get(SCH, created.getFormUuid());
assertThat(upgraded.getFormUuid()).isEqualTo(created.getFormUuid());
assertThat(upgraded.getCurrentVersion()).isEqualTo(1);
assertThat(upgraded.getDraftVersion()).isEqualTo(2);
assertThat(objectMapper.writeValueAsString(upgraded)).doesNotContain("\"id\"");
assertThat(objectMapper.writeValueAsString(defService.version(SCH, created.getFormUuid(), 1)))
.doesNotContain("\"id\"");
}
@Test
@DisplayName("code 从未发布时可改(同步草稿行冗余列),发布后不可改(契约 §1)")
void codeEditableOnlyBeforePublish() {
FormModels.FormView created = defService.create(SCH, create("form-center", "draft_code"));
String uuid = created.getFormUuid();
FormModels.FormSaveRequest rename = save(uuid, SCHEMA_A, null);
rename.setCode("final_code");
defService.save(SCH, rename);
assertThat(defService.get(SCH, uuid).getCode()).isEqualTo("final_code");
// 草稿行的冗余 code 跟着走,否则 uk(sch_id,app_code,code,version) 与 resolve 都对不上
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).isEqualTo(SCHEMA_A);
defService.publish(SCH, uuid, null);
FormModels.FormSaveRequest again = save(uuid, null, null);
again.setCode("other_code");
assertThatThrownBy(() -> defService.save(SCH, again))
.isInstanceOf(BizException.class).hasMessageContaining("code 发布后不可改");
assertThatThrownBy(() -> defService.create(SCH, create("form-center", "final_code")))
.hasMessageContaining("code 已存在");
}
@Test
@DisplayName("软删表单后:列表与 get 都查不到,但同 code 仍被 uk 占用并给出可理解提示")
void softDeletedFormKeepsItsCode() {
String uuid = defService.create(SCH, create("form-center", "deleted_code")).getFormUuid();
defService.softDelete(SCH, uuid);
assertThat(defService.list(SCH, null, null)).isEmpty();
assertThatThrownBy(() -> defService.get(SCH, uuid)).hasMessageContaining("表单不存在");
assertThatThrownBy(() -> defService.create(SCH, create("form-center", "deleted_code")))
.hasMessageContaining("软删行仍占用该 code");
}
// ==================== resolve(契约 §1) ====================
@Test
@DisplayName("resolve:草稿不暴露;发布后可解析;<b>本校遮蔽平台级且不回落</b>;未发布/停用给业务级错误")
void resolveAppliesSchoolShadowing() {
// 平台级(sch_id=0)同 code 表单
String platformUuid = defService.create(Form.PLATFORM_SCH_ID, create("form-center", "shadow")).getFormUuid();
defService.save(Form.PLATFORM_SCH_ID, save(platformUuid, SCHEMA_A, null));
defService.publish(Form.PLATFORM_SCH_ID, platformUuid, null);
// 本校同 code:先草稿——resolve 不能穿透到平台级
String schoolUuid = defService.create(SCH, create("form-center", "shadow")).getFormUuid();
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "shadow"))
.isInstanceOf(BizException.class).hasMessageContaining("尚未发布");
defService.save(SCH, save(schoolUuid, SCHEMA_B, null));
defService.publish(SCH, schoolUuid, null);
FormModels.ResolvedFormView resolved = defService.resolve(SCH, "form-center", "shadow");
assertThat(resolved.getFormUuid()).isEqualTo(schoolUuid);
assertThat(resolved.getVersion()).isEqualTo(1);
assertThat(resolved.getGroups()).isNotNull();
// 本校停用:遮蔽仍在,不回落平台级(否则"本校遮蔽"成了可绕过的建议)
defService.disable(SCH, schoolUuid);
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "shadow"))
.isInstanceOf(BizException.class).hasMessageContaining("已停用");
// 别校请求同一 code:拿不到本校的行,命中平台级
assertThat(defService.resolve(OTHER_SCH, "form-center", "shadow").getFormUuid()).isEqualTo(platformUuid);
}
@Test
@DisplayName("resolve 只按 app_code 分命名空间:同 code 不同 app_code 各回各家")
void resolveIsScopedByAppCode() {
String center = defService.create(SCH, create("form-center", "cross_app")).getFormUuid();
String quick = defService.create(SCH, create("honor", "cross_app")).getFormUuid();
for (String pair : new String[]{center, quick}) {
defService.save(SCH, save(pair, SCHEMA_A, null));
defService.publish(SCH, pair, null);
}
assertThat(defService.resolve(SCH, "form-center", "cross_app").getFormUuid()).isEqualTo(center);
assertThat(defService.resolve(SCH, "honor", "cross_app").getFormUuid()).isEqualTo(quick);
}
@Test
@DisplayName("resolve 未发布过 / 不存在的 code:明确错误,不静默返回空")
void resolveRejectsUnknownAndUnpublished() {
String uuid = defService.create(SCH, create("form-center", "never_published")).getFormUuid();
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "never_published"))
.hasMessageContaining("尚未发布");
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "no_such_code"))
.hasMessageContaining("表单不存在");
}
// ==================== 生命周期 / 表单级配置(方案 §5.4) ====================
@Test
@DisplayName("停用只断填报入口、恢复回当前版本(不产生新版本行)")
void disableAndEnableRestoreCurrentVersion() {
String uuid = defService.create(SCH, create("form-center", "lifecycle")).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
assertThat(defService.disable(SCH, uuid).getStatusName()).isEqualTo("DISABLED");
assertThatThrownBy(() -> defService.resolve(SCH, "form-center", "lifecycle")).isInstanceOf(BizException.class);
// 数据面读接口不受停用影响(方案 §5.4):管理端仍可看版本历史
assertThat(defService.versions(SCH, uuid)).hasSize(1);
assertThatThrownBy(() -> defService.disable(SCH, uuid)).hasMessageContaining("仅已发布的表单可停用");
FormModels.FormView enabled = defService.enable(SCH, uuid);
assertThat(enabled.getStatusName()).isEqualTo("PUBLISHED");
assertThat(enabled.getCurrentVersion()).isEqualTo(1);
assertThat(defService.versions(SCH, uuid)).hasSize(1);
assertThat(defService.resolve(SCH, "form-center", "lifecycle").getVersion()).isEqualTo(1);
assertThatThrownBy(() -> defService.enable(SCH, uuid)).hasMessageContaining("仅停用的表单可恢复");
}
@Test
@DisplayName("V12 + 表单级配置:状态定义/面向人群/限一次/截止/报名号都在头表,改它们不升版(不进快照)")
void formLevelConfigDoesNotCreateVersion() {
String uuid = defService.create(SCH, create("form-center", "config_test")).getFormUuid();
defService.save(SCH, save(uuid, SCHEMA_A, null));
defService.publish(SCH, uuid, null);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(uuid);
config.setAudience(FormEnums.Audience.TEACHER.getCode());
config.setFillLimit(FormEnums.FillLimit.ONCE.getCode());
config.setDeadline("2026-11-30");
config.setAnswerNoEnabled(1);
config.setGroupTag("honors");
FormModels.FormView configured = defService.updateConfig(SCH, config);
assertThat(configured.getCurrentVersion()).isEqualTo(1);
assertThat(configured.getDeadline()).isEqualTo("2026-11-30 23:59:59");
assertThat(configured.getAudience()).isEqualTo(2);
assertThat(configured.getFillLimit()).isEqualTo(1);
assertThat(configured.getAnswerNoEnabled()).isEqualTo(1);
assertThat(configured.getGroupTag()).isEqualTo("honors");
// 配置改动不产生新版本行
assertThat(defService.versions(SCH, uuid)).hasSize(1);
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).isEqualTo(SCHEMA_A);
FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest();
defs.setUuid(uuid);
defs.setStatusDefs(null);
// V12:未配置时默认集合就是待处理/已处理
assertThat(defService.updateStatusDefs(SCH, defs).getStatusDefs())
.extracting(FormModels.StatusDefView::getLabel).containsExactly("待处理", "已处理");
FormModels.StatusDefView draft = new FormModels.StatusDefView();
draft.setCode("draft");
draft.setLabel("初筛");
FormModels.StatusDefView passed = new FormModels.StatusDefView();
passed.setCode("passed");
passed.setLabel("通过");
defs.setStatusDefs(java.util.Arrays.asList(draft, passed));
assertThat(defService.updateStatusDefs(SCH, defs).getStatusDefs())
.extracting(FormModels.StatusDefView::getCode).containsExactly("draft", "passed");
}
@Test
@DisplayName("配置项取值范围严格:audience 只 1/2、fillLimit 与开关只 0/1、groupTag ≤64")
void configValueRangesAreGuarded() {
String uuid = defService.create(SCH, create("form-center", "config_guard")).getFormUuid();
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(uuid);
config.setAudience(3);
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("audience");
config.setAudience(1);
config.setFillLimit(2);
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("fillLimit");
config.setFillLimit(0);
config.setAnswerNoEnabled(7);
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("answerNoEnabled");
config.setAnswerNoEnabled(0);
StringBuilder longTag = new StringBuilder();
for (int i = 0; i < 65; i++) {
longTag.append('t');
}
config.setGroupTag(longTag.toString());
assertThatThrownBy(() -> defService.updateConfig(SCH, config)).hasMessageContaining("groupTag");
}
// ==================== 辅助 ====================
private FormModels.FormCreateRequest create(String appCode, String code) {
FormModels.FormCreateRequest req = new FormModels.FormCreateRequest();
req.setAppCode(appCode);
req.setCode(code);
req.setName("测试表单 " + code);
return req;
}
private FormModels.FormSaveRequest save(String uuid, String schemaJson, String changeLog) {
FormModels.FormSaveRequest req = new FormModels.FormSaveRequest();
req.setUuid(uuid);
req.setSchemaJson(schemaJson);
req.setChangeLog(changeLog);
return req;
}
}
@@ -0,0 +1,226 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 选项字典集成测试(契约 §2.1 / 方案 §4.3③b,真实 MySQL)。
*
* <p>V2 的后半句"<b>改 label 不改 value 后,历史答卷筛选与分组统计结果不变</b>"在字典侧的
* 依据就在这里:value 是答案存储值、label 只是展示名;再加两条字典独有的不变式——
* <b>软删条目仍通过提交校验</b>(V38)与<b>活引用不进版本快照</b>。</p>
*/
@SpringBootTest
@Transactional
@DisplayName("FormDictService - 两级字典与活引用(契约 §2.1)")
class FormDictServiceIT {
private static final long SCH = 990101L;
private static final long OTHER_SCH = 990102L;
private static final String DICT_SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"searchable\":true,"
+ "\"options\":{\"source\":\"dict:grade\"}},"
+ "{\"key\":\"reason\",\"label\":\"理由\",\"type\":\"textarea\"}]}]}";
@Autowired
private FormDictService dictService;
@Autowired
private FormDefService defService;
@Test
@DisplayName("平台级(sch_id=0)字典:学校可见可用、不可改;校级同层才能改")
void platformDictIsVisibleButNotEditableBySchool() {
FormModels.DictView platform = dictService.saveDict(Form.PLATFORM_SCH_ID, "nation", "民族");
dictService.saveItem(Form.PLATFORM_SCH_ID, "nation", "han", "汉族", 1);
assertThat(platform.getPlatformLevel()).isTrue();
// 可见可用:设计器候选里有它,解析域能命中它
assertThat(dictService.listSelectable(SCH)).extracting(FormModels.DictView::getCode).contains("nation");
assertThat(dictService.allowedValues(SCH, "nation")).containsExactly("han");
// 不可改:校级域的管理面精确匹配本校,碰不到平台级行
assertThatThrownBy(() -> dictService.saveItem(SCH, "nation", "hui", "回族", 2))
.isInstanceOf(BizException.class).hasMessageContaining("字典不存在");
assertThatThrownBy(() -> dictService.deleteDict(SCH, "nation"))
.isInstanceOf(BizException.class).hasMessageContaining("字典不存在");
// 平台级自己可以改
assertThat(dictService.saveItem(Form.PLATFORM_SCH_ID, "nation", "hui", "回族", 2).getValue())
.isEqualTo("hui");
// 学校自己的管理清单不含平台级
assertThat(dictService.listOwn(SCH)).isEmpty();
}
@Test
@DisplayName("同 code 的校级字典遮蔽平台级(契约 §1 同一套校域规则)")
void schoolDictShadowsPlatformDict() {
dictService.saveDict(Form.PLATFORM_SCH_ID, "grade", "平台年级");
dictService.saveItem(Form.PLATFORM_SCH_ID, "grade", "p1", "平台一档", 1);
dictService.saveDict(SCH, "grade", "本校年级");
dictService.saveItem(SCH, "grade", "s1", "本校一档", 1);
assertThat(dictService.allowedValues(SCH, "grade")).containsExactly("s1");
assertThat(dictService.allowedValues(OTHER_SCH, "grade")).containsExactly("p1");
List<FormModels.DictView> selectable = dictService.listSelectable(SCH);
assertThat(selectable).hasSize(1);
assertThat(selectable.get(0).getSchId()).isEqualTo(SCH);
assertThat(selectable.get(0).getPlatformLevel()).isFalse();
}
@Test
@DisplayName("V2(字典侧):改 label 不动 value——value 集合逐元素不变,历史统计口径不受影响")
void labelChangeKeepsValueSetStable() {
dictService.saveDict(SCH, "grade", "年级");
dictService.saveItem(SCH, "grade", "g2", "高二年级", 1);
dictService.saveItem(SCH, "grade", "g3", "高三年级", 2);
Set<String> valuesBefore = dictService.allowedValues(SCH, "grade");
FormModels.DictItemView renamed = dictService.saveItem(SCH, "grade", "g2", "高二", 1);
assertThat(renamed.getLabel()).isEqualTo("高二");
assertThat(dictService.allowedValues(SCH, "grade")).isEqualTo(valuesBefore);
assertThat(inlineValues(SCH, "grade")).containsExactly("g2", "g3");
}
@Test
@DisplayName("软删条目:设计器候选里消失,提交校验仍放行(V38);同 value 重新编辑=放回可选集")
void softDeletedItemStillPassesSubmitCheck() {
dictService.saveDict(SCH, "subject", "学科");
dictService.saveItem(SCH, "subject", "math", "数学", 1);
dictService.saveItem(SCH, "subject", "phy", "物理", 2);
dictService.deleteItem(SCH, "subject", "phy");
assertThat(inlineValues(SCH, "subject")).containsExactly("math");
assertThat(dictService.allowedValues(SCH, "subject")).contains("math", "phy");
// 旧答卷原样重存的通道:条目被重新编辑即回到可选集
dictService.saveItem(SCH, "subject", "phy", "物理", 2);
assertThat(inlineValues(SCH, "subject")).containsExactly("math", "phy");
}
@Test
@DisplayName("内联展开(契约 §2.1:字典不进对外契约):source 变 {label,value} 数组、按 sort 有序")
void expandSchemaInlinesDictOptions() {
dictService.saveDict(SCH, "grade", "年级");
dictService.saveItem(SCH, "grade", "g3", "高三", 2);
dictService.saveItem(SCH, "grade", "g2", "高二", 1);
String expanded = dictService.expandSchema(SCH, DICT_SCHEMA);
assertThat(expanded).doesNotContain("dict:grade");
assertThat(FormSchemaValidator.dictRefCodes(expanded)).isEmpty();
assertThat(inlineValues(SCH, "grade")).containsExactly("g2", "g3");
// 无字典引用的字段不受影响
assertThat(expanded).contains("\"key\":\"reason\"");
// 字典不存在 → 降级空数组,读路径不炸
String orphan = dictService.expandSchema(SCH,
"{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":[{\"key\":\"x\",\"label\":\"X\","
+ "\"type\":\"select\",\"options\":{\"source\":\"dict:gone\"}}]}]}");
assertThat(orphan).contains("\"options\":[]");
}
@Test
@DisplayName("发布前可解析性:未知字典、无可用条目的字典都被拒;引用平台级字典可以发布")
void publishRequiresResolvableDictRefs() {
String uuid = createWithSchema("dict_ref_unknown", DICT_SCHEMA);
assertThatThrownBy(() -> defService.publish(SCH, uuid, null))
.isInstanceOf(BizException.class).hasMessageContaining("无法解析:dict:grade");
dictService.saveDict(SCH, "grade", "年级");
assertThatThrownBy(() -> defService.publish(SCH, uuid, null))
.isInstanceOf(BizException.class).hasMessageContaining("没有可用条目");
dictService.saveItem(SCH, "grade", "g2", "高二", 1);
assertThat(defService.publish(SCH, uuid, null).getStatusName()).isEqualTo("PUBLISHED");
// 引用平台级字典同样可发布(学校可见可用)
dictService.saveDict(Form.PLATFORM_SCH_ID, "nation", "民族");
dictService.saveItem(Form.PLATFORM_SCH_ID, "nation", "han", "汉族", 1);
String nationUuid = createWithSchema("nation_ref",
"{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":[{\"key\":\"n\",\"label\":\"民族\","
+ "\"type\":\"select\",\"options\":{\"source\":\"dict:nation\"}}]}]}");
assertThat(defService.publish(SCH, nationUuid, null).getCurrentVersion()).isEqualTo(1);
}
@Test
@DisplayName("活引用不进版本快照:老行存 source,读接口即时反映字典改名(平台改一处、引用表单全生效)")
void dictIsLiveReferenceNotVersionedSnapshot() {
dictService.saveDict(SCH, "grade", "年级");
dictService.saveItem(SCH, "grade", "g2", "高二年级", 1);
String uuid = createWithSchema("live_ref", DICT_SCHEMA);
defService.publish(SCH, uuid, null);
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).contains("dict:grade");
assertThat(firstOptionLabel(SCH, uuid, "grade")).isEqualTo("高二年级");
dictService.saveItem(SCH, "grade", "g2", "高二", 1);
// 版本行一个字节都没变,读接口已经改名——这正是"活引用"的价值(方案 §4.3③b)
assertThat(defService.version(SCH, uuid, 1).getSchemaJson()).contains("dict:grade");
assertThat(firstOptionLabel(SCH, uuid, "grade")).isEqualTo("高二");
}
@Test
@DisplayName("字典 code 是 schema 的引用键:形态受限,且软删后同 code 保存即恢复")
void dictCodeRules() {
assertThatThrownBy(() -> dictService.saveDict(SCH, "民 族", "民族"))
.isInstanceOf(BizException.class).hasMessageContaining("code 仅限");
assertThatThrownBy(() -> dictService.saveDict(SCH, "ok", " "))
.isInstanceOf(BizException.class).hasMessageContaining("name 不能为空");
dictService.saveDict(SCH, "ok", "原名");
dictService.deleteDict(SCH, "ok");
assertThat(dictService.listOwn(SCH)).isEmpty();
assertThatThrownBy(() -> dictService.saveItem(SCH, "ok", "v", "V", 1))
.hasMessageContaining("字典不存在或已删除");
assertThat(dictService.saveDict(SCH, "ok", "恢复后名").getPlatformLevel()).isFalse();
assertThat(dictService.listOwn(SCH)).hasSize(1);
}
// ==================== 辅助 ====================
private List<String> inlineValues(Long schId, String dictCode) {
return dictService.inlineOptions(schId, dictCode).findValuesAsText("value");
}
/** 从 resolve 结果里取某字段展开后的第一个 option 的 label(验证"内联展开"真的发生在读路径上) */
private String firstOptionLabel(Long schId, String formUuid, String fieldKey) {
String code = defService.get(schId, formUuid).getCode();
FormModels.ResolvedFormView resolved = defService.resolve(schId, "form-center", code);
JsonNode groups = (JsonNode) resolved.getGroups();
for (JsonNode group : groups) {
for (JsonNode field : group.path("fields")) {
if (fieldKey.equals(field.path("key").asText())) {
JsonNode options = field.path("options");
return options.isArray() && options.size() > 0 ? options.get(0).path("label").asText() : null;
}
}
}
return null;
}
private String createWithSchema(String code, String schemaJson) {
FormModels.FormCreateRequest req = new FormModels.FormCreateRequest();
req.setCode(code);
req.setName("字典测试 " + code);
FormModels.FormView created = defService.create(SCH, req);
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(created.getFormUuid());
save.setSchemaJson(schemaJson);
defService.save(SCH, save);
return created.getFormUuid();
}
}