diff --git a/docs/T2-report.md b/docs/T2-report.md
new file mode 100644
index 0000000..b8686de
--- /dev/null
+++ b/docs/T2-report.md
@@ -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 是纯后端任务),不产生推送。
+
+—— 全文完 ——
diff --git a/sql/t2_definition_domain.sql b/sql/t2_definition_domain.sql
new file mode 100644
index 0000000..396ef7e
--- /dev/null
+++ b/sql/t2_definition_domain.sql
@@ -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)';
diff --git a/src/main/java/com/yunhai/formcenter/common/FormRuntimeRules.java b/src/main/java/com/yunhai/formcenter/common/FormRuntimeRules.java
new file mode 100644
index 0000000..11a6064
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/common/FormRuntimeRules.java
@@ -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)。
+ *
+ *
放在 common 而不是 service:提交接口(T3)、只读态判定(T7 前端要的字段)、
+ * 限次登记(fill_quota 的 limit_key)都要同一份口径,写进 service 就会被复制两遍。
+ */
+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();
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java b/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java
new file mode 100644
index 0000000..4ba221a
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java
@@ -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①②③⑤)。
+ *
+ * 与 svc-hall {@code FormValidator} 同源处:14 类控件命名一次冻结、一期放行 11 类、
+ * {@code ^[A-Za-z0-9_]{1,64}$} 的 key 规则、属性白名单外一律拒绝(防"存库 JS/eval"类契约外语义渗入)。
+ * 关键差异(新契约的三处硬修订):
+ *
+ * - options 必须是 {@code {label,value}} 对象数组,字符串数组被拒(契约 §2 / V2)——
+ * svc-hall 的 label 即 value,老师把"高二年级"改成"高二"立刻打穿历史统计;
+ * - options 可为字典引用对象 {@code {"source":"dict:{code}"}}(契约 §2.1);
+ * - 白名单 16 项 → 19 项,新增 {@code precision / unit / searchable}(契约 §2)。
+ *
+ *
+ * 本类只做纯静态校验(不碰库):字典引用是否可解析属于跨表事实,在
+ * {@code FormDictService#assertRefsResolvable} 发布路径上查。提交值校验(按钉住版本)属 T3。
+ */
+public final class FormSchemaValidator {
+
+ /** 14 类控件命名一次冻结(契约 §2、方案 §4.2) */
+ public static final Set 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 PHASE1_TYPES = new LinkedHashSet<>(Arrays.asList(
+ "text", "textarea", "number", "date", "select", "radio", "upload",
+ "checkbox", "multiselect", "phone", "image"));
+
+ /** 需要 options 的四类选项控件(inline 或 dict 引用二选一,且必须非空) */
+ public static final Set 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 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 ALLOWED_GROUP_PROPS = new LinkedHashSet<>(Arrays.asList(
+ "key", "label", "fields"));
+
+ /** 选项对象白名单(契约 §2:{@code {label,value}}) */
+ private static final Set 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 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 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 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 在不同版本里必须是同一类控件。
+ *
+ * 依据:契约 §2"改 key = 换字段(视同删旧增新)"+ answer_index 按 type 收敛检索算子
+ * (等值/区间/前缀,契约 §2)、{@code v_str/v_num/v_date} 三列分类型落值(契约 §10)。
+ * 若允许同 key 换 type,历史索引行与新语义错配,V8"删字段后历史仍可筛"当场失效。
+ * 删字段与新增字段都不受本检查约束(V8/V9 的正常路径);label/options/required/searchable 可改。
+ */
+ public static void assertFieldTypesStable(String oldSchemaJson, String newSchemaJson) {
+ Map oldFields = fieldsByKeys(oldSchemaJson);
+ Map newFields = fieldsByKeys(newSchemaJson);
+ for (Map.Entry 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 searchableKeys(String schemaJson) {
+ List result = new ArrayList<>();
+ for (Map.Entry 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 dictRefCodes(String schemaJson) {
+ List 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 fieldsByKeys(String schemaJson) {
+ JsonNode root = parse(schemaJson, "表单结构损坏(请联系管理员)");
+ Map 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 allowed, String what) {
+ Iterator 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();
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/common/FormStatusDefs.java b/src/main/java/com/yunhai/formcenter/common/FormStatusDefs.java
new file mode 100644
index 0000000..bf38b1e
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/common/FormStatusDefs.java
@@ -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})。
+ *
+ * 红线复述(方案 §5.3):状态是"标记"不是"流转"——不校验谁能从 A 到 B、无条件分支、
+ * 无会签、无超时;需要流转去流程中心。所以这里只有"定义集合"的增删改,没有任何转移规则。
+ *
+ *
+ * - 存储形态:{@code [{code,label,is_deleted}]}(契约 §10 注释定的 JSON 形状,字符串 code + 软删定义);
+ * - 列值为 null 时用默认集合 待处理/已处理(V12"默认含"的落点,不靠建表单时写入,
+ * 这样"从未配过状态"与"配了又清空"是两种可区分的事实);
+ * - 删状态=软删定义({@code is_deleted=true}),定义行不物理删——历史答卷要回显
+ * "已废弃:xxx"(V13,读路径消费)。
+ *
+ */
+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 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 空或超长或重复
+ * (含软删 code——复用已废弃 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 codes = new HashSet<>();
+ ArrayNode out = MAPPER.createArrayNode();
+ boolean anyLive = false;
+ for (JsonNode node : root) {
+ if (!node.isObject()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "状态定义条目必须是对象");
+ }
+ Iterator 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 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 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 liveDefs(String statusDefsJson) {
+ List result = new ArrayList<>();
+ for (StatusDef def : effective(statusDefsJson)) {
+ if (!def.isDeleted()) {
+ result.add(def);
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java
new file mode 100644
index 0000000..3d05121
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java
@@ -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} 把关):
+ * 表单草稿/保存升版/发布/停用/恢复/软删/另存副本/表单级配置/状态定义,以及选项字典两表。
+ *
+ * 契约纪律:
+ *
+ * - 身份一律会话自取,不接外部 schId 作为归属——{@code schId} 参数只是超管跨校/平台级
+ * 上下文的"目标域"选择,校级管理员传别的值直接拒(契约 §7 身份行、V17 同族);
+ * - 出入参只出现 {@code uuid}/{@code code},不出现库内 {@code id}(契约 §1,V5);
+ * - 表单级配置与状态定义不进版本快照(方案 §5.1/§5.4),所以改它们不需要升版。
+ *
+ */
+@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 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 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 的接口侧断言点):只有显式声明 searchable 的字段才进候选,
+ * 取当前发布版本的 schema;未发布时返回空列表(草稿字段不参与筛选)。
+ */
+ @GetMapping("/filterFields")
+ public List 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 dictList(BoaUser user, @RequestParam(required = false) Long schId) {
+ return dictService.listOwn(adminGuard.requireAdmin(user, schId));
+ }
+
+ /** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
+ @GetMapping("/dict/selectable")
+ public List 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());
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormResolveController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormResolveController.java
new file mode 100644
index 0000000..f580759
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormResolveController.java
@@ -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 的同一条解析路径)。
+ *
+ * 纪律:
+ *
+ * - 租户域取会话校,绝不接受外部传 schId(契约 §7 身份行:任何身份注入被禁止;
+ * 契约 §1 校域不变式:{@code answer.sch_id} 恒=会话校);
+ * - 入口只用 {@code code}(人可读、跨环境稳定),返回体里的运行时身份是 {@code formUuid},
+ * 库内 {@code id} 不外露(契约 §1、V5);
+ * - options 已按字典内联展开,字典本身不进对外契约(契约 §2.1)。
+ *
+ */
+@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());
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/Form.java b/src/main/java/com/yunhai/formcenter/entity/Form.java
new file mode 100644
index 0000000..6dc7b78
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/Form.java
@@ -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):一行=一条表单线,跨版本不变。
+ *
+ * 为什么必须有头表(方案 §5.1):uuid 要跨版本不变(V5)就不能长在版本行上;状态定义
+ * "属于表单、会被改、不随版本快照"(§5.4);"当前发布版本"指针是 form 级;草稿行与发布行是
+ * 同一头表下两种生命周期的版本行。
+ *
+ * 标识分工(契约 §1):{@code id} 只做库内定位、绝不外露;{@code uuid}=数据面运行时身份;
+ * {@code code}=配置期引用 + 入口寻址,同校 + 同 {@code app_code} 内唯一、发布后不可改。
+ *
+ * 表单级配置(audience/fill_limit/deadline/answer_no_enabled/status_defs/group_tag)不进版本
+ * 快照——升版只换 schema 行,这些列原地可改(方案 §5.1/§5.4)。
+ */
+@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;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormDict.java b/src/main/java/com/yunhai/formcenter/entity/FormDict.java
new file mode 100644
index 0000000..18e5fba
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/FormDict.java
@@ -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} 平台级(超管维护,
+ * 学校可见、可用、不可改),其余为校级(仅本校表单可引用,sch_id 天然隔离)。
+ *
+ * {@code code} 是 schema 里 {@code options.source="dict:{code}"} 的引用键,因此建后不可改
+ * (改 code=换字典,会打穿所有引用它的表单)。{@code is_deleted} 软删后不再可被新引用,
+ * 但历史 schema 的降级显示由读路径负责。
+ */
+@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;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormDictItem.java b/src/main/java/com/yunhai/formcenter/entity/FormDictItem.java
new file mode 100644
index 0000000..a4d3b9e
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/FormDictItem.java
@@ -0,0 +1,49 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 字典条目(契约 §2.1 / 方案 §4.3③b):value 不可变、label 可改(D11 同款保护——字典改名
+ * 不打穿历史答卷,这是字典机制能安全存在的前提)。
+ *
+ * 软删条目({@code is_deleted=1}):value 仍通过提交校验(旧答卷可原样重存,V38),
+ * 仅设计器不可新选。因此本表对外有两个读取口径——
+ * {@code 设计器可选=未删} 与 {@code 提交放行=含已删},勿混用。
+ */
+@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;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormVersion.java b/src/main/java/com/yunhai/formcenter/entity/FormVersion.java
new file mode 100644
index 0000000..b050624
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/FormVersion.java
@@ -0,0 +1,65 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+
+/**
+ * 表单版本行(契约 §10 / 方案 §3.2):{@code (sch_id, app_code, code, version)} 唯一确定一个
+ * 永不改变的版本行——已发布行禁止就地修改,改版=新增行(V7)。
+ *
+ * 草稿行与发布行同表(方案 §5.1):区分依据是"是否已发布过",即
+ * {@code publish_time != null},以及头表 {@code form.current_version} 指针是否指到它。
+ * 唯一可写的例外是未发布草稿行的 {@code schema_json}/{@code change_log}(草稿直改),
+ * 发布动作只补 {@code publish_time}——发布后该行内容逐字节冻结,缓存声明才成立(方案 §3.2
+ * "schema_json 内容不可变→可长缓存;resolve 结果会因升版失效→短 TTL")。
+ *
+ * 字典引用 {@code options.source="dict:{code}"} 原样存在 schema_json 里,不进快照展开
+ * (方案 §4.3③b:活引用是它的价值,读接口下发时才内联展开)。
+ */
+@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;
+}
diff --git a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java
new file mode 100644
index 0000000..a300d7f
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java
@@ -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
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/model/FormModels.java b/src/main/java/com/yunhai/formcenter/model/FormModels.java
new file mode 100644
index 0000000..c25dfb9
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/FormModels.java
@@ -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 的响应侧形状)。
+ *
+ * 响应体一律不带 {@code id}:契约 §1 把 id 定为"库内定位、绝不外露",V5 要断言
+ * "id 不出现在任何响应外层"。因此管理端与数据面都用 {@code uuid} 寻址表单;字典没有 uuid,
+ * 用 {@code (域, code)} 寻址、条目用 {@code (dictCode, value)}(value 建后不可变,天然是稳定键)。
+ */
+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 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 已按字典内联展开——字典是中心内部组织方式,不进对外契约(契约 §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 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 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 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;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormDictItemRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormDictItemRepository.java
new file mode 100644
index 0000000..a1b45d2
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/FormDictItemRepository.java
@@ -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):
+ *
+ * - {@code …AndIsDeleted(…, 0)} = 设计器可选/内联展开(软删条目不再出现在候选里);
+ * - {@code …ByDictId…}(不带 is_deleted)= 提交校验放行集,含软删条目——
+ * 旧答卷能原样重存(V38)全靠这一条。
+ *
+ */
+public interface FormDictItemRepository extends JpaRepository {
+
+ List findByDictIdOrderBySortAscIdAsc(Long dictId);
+
+ List findByDictIdAndIsDeletedOrderBySortAscIdAsc(Long dictId, Integer isDeleted);
+
+ /** value 建后不可变,故 (dict_id, value) 即条目的稳定定位键(对外不必外露 id) */
+ Optional findByDictIdAndValue(Long dictId, String value);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormDictRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormDictRepository.java
new file mode 100644
index 0000000..5b3db5c
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/FormDictRepository.java
@@ -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 {
+
+ Optional findBySchIdAndCode(Long schId, String code);
+
+ List findBySchIdAndIsDeletedOrderByCodeAsc(Long schId, Integer isDeleted);
+
+ /** 设计器选择器取域:会话校 ∪ 平台级(遮蔽规则在应用层按 code 判) */
+ List findBySchIdInAndIsDeletedOrderByCodeAsc(Collection schIds, Integer isDeleted);
+
+ @Query("select d from FormDict d where d.code = :code and d.schId in (:schIds) and d.isDeleted = 0")
+ List findResolveCandidates(@Param("code") String code,
+ @Param("schIds") Collection schIds);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java
new file mode 100644
index 0000000..68f40c9
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java
@@ -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