From 03f6a971c3206be837be148ea40d790a09aca484 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 14 Oct 2026 15:23:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(definition):=20T2=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=C2=B7=E5=AE=9A=E4=B9=89=E5=9F=9F=E2=80=94=E2=80=94=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=A8=A1=E5=9E=8B=E3=80=81schema=20=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=99=A8=E3=80=81=E9=80=89=E9=A1=B9=E5=AD=97=E5=85=B8=E4=B8=8E?= =?UTF-8?q?=E5=8F=A6=E5=AD=98=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定义与答卷物理分离的第一步:把"契约"落进可执行代码,让后续答卷域只需消费不必再解释。 - 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 --- docs/T2-report.md | 122 ++++ sql/t2_definition_domain.sql | 91 +++ .../formcenter/common/FormRuntimeRules.java | 86 +++ .../common/FormSchemaValidator.java | 466 +++++++++++++ .../formcenter/common/FormStatusDefs.java | 216 ++++++ .../controller/api/FormAdminController.java | 202 ++++++ .../controller/api/FormResolveController.java | 60 ++ .../com/yunhai/formcenter/entity/Form.java | 103 +++ .../yunhai/formcenter/entity/FormDict.java | 41 ++ .../formcenter/entity/FormDictItem.java | 49 ++ .../yunhai/formcenter/entity/FormVersion.java | 65 ++ .../yunhai/formcenter/enums/FormEnums.java | 90 +++ .../yunhai/formcenter/model/FormModels.java | 196 ++++++ .../repository/FormDictItemRepository.java | 25 + .../repository/FormDictRepository.java | 30 + .../formcenter/repository/FormRepository.java | 38 ++ .../repository/FormVersionRepository.java | 36 + .../formcenter/service/FormAdminGuard.java | 72 ++ .../formcenter/service/FormDefService.java | 627 ++++++++++++++++++ .../formcenter/service/FormDictService.java | 334 ++++++++++ .../common/FormRuntimeRulesTest.java | 89 +++ .../common/FormSchemaValidatorTest.java | 310 +++++++++ .../formcenter/common/FormStatusDefsTest.java | 101 +++ .../yunhai/formcenter/service/FormCopyIT.java | 210 ++++++ .../formcenter/service/FormDefServiceIT.java | 380 +++++++++++ .../formcenter/service/FormDictServiceIT.java | 226 +++++++ 26 files changed, 4265 insertions(+) create mode 100644 docs/T2-report.md create mode 100644 sql/t2_definition_domain.sql create mode 100644 src/main/java/com/yunhai/formcenter/common/FormRuntimeRules.java create mode 100644 src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java create mode 100644 src/main/java/com/yunhai/formcenter/common/FormStatusDefs.java create mode 100644 src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java create mode 100644 src/main/java/com/yunhai/formcenter/controller/api/FormResolveController.java create mode 100644 src/main/java/com/yunhai/formcenter/entity/Form.java create mode 100644 src/main/java/com/yunhai/formcenter/entity/FormDict.java create mode 100644 src/main/java/com/yunhai/formcenter/entity/FormDictItem.java create mode 100644 src/main/java/com/yunhai/formcenter/entity/FormVersion.java create mode 100644 src/main/java/com/yunhai/formcenter/enums/FormEnums.java create mode 100644 src/main/java/com/yunhai/formcenter/model/FormModels.java create mode 100644 src/main/java/com/yunhai/formcenter/repository/FormDictItemRepository.java create mode 100644 src/main/java/com/yunhai/formcenter/repository/FormDictRepository.java create mode 100644 src/main/java/com/yunhai/formcenter/repository/FormRepository.java create mode 100644 src/main/java/com/yunhai/formcenter/repository/FormVersionRepository.java create mode 100644 src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java create mode 100644 src/main/java/com/yunhai/formcenter/service/FormDefService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/FormDictService.java create mode 100644 src/test/java/com/yunhai/formcenter/common/FormRuntimeRulesTest.java create mode 100644 src/test/java/com/yunhai/formcenter/common/FormSchemaValidatorTest.java create mode 100644 src/test/java/com/yunhai/formcenter/common/FormStatusDefsTest.java create mode 100644 src/test/java/com/yunhai/formcenter/service/FormCopyIT.java create mode 100644 src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java create mode 100644 src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java 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"类契约外语义渗入)。 + * 关键差异(新契约的三处硬修订):

+ *
    + *
  1. options 必须是 {@code {label,value}} 对象数组,字符串数组被拒(契约 §2 / V2)—— + * svc-hall 的 label 即 value,老师把"高二年级"改成"高二"立刻打穿历史统计;
  2. + *
  3. options 可为字典引用对象 {@code {"source":"dict:{code}"}}(契约 §2.1);
  4. + *
  5. 白名单 16 项 → 19 项,新增 {@code precision / unit / searchable}(契约 §2)。
  6. + *
+ * + *

本类只做纯静态校验(不碰库):字典引用是否可解析属于跨表事实,在 + * {@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 { + + /** 唯一键定位(契约 §1:同校 + 同 app_code 内 code 唯一);软删行也在内,好给出"code 被占用"的点名提示 */ + Optional
findBySchIdAndAppCodeAndCode(Long schId, String appCode, String code); + + Optional 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 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 findResolveCandidates(@Param("code") String code, + @Param("appCode") String appCode, + @Param("schIds") Collection schIds); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormVersionRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormVersionRepository.java new file mode 100644 index 0000000..a5854a2 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/FormVersionRepository.java @@ -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; + +/** + * 版本行仓库:没有任何"修改已发布行"的方法——行不可变由服务层的写路径共同保证 + * (V7)。这里只提供定位与 max(version) 取号。 + */ +public interface FormVersionRepository extends JpaRepository { + + List findByFormIdOrderByVersionDesc(Long formId); + + Optional findByFormIdAndVersion(Long formId, Integer version); + + /** 未发布的草稿行(同一表单最多一条:发布即补 publish_time,方案 §5.1 两种生命周期) */ + Optional findFirstByFormIdAndPublishTimeIsNullOrderByVersionDesc(Long formId); + + /** 列表页"有未发布草稿"徽标:一次取回当页全部表单的草稿行,避免 N+1 */ + List findByFormIdInAndPublishTimeIsNull(java.util.Collection 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); +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java new file mode 100644 index 0000000..be0b032 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java @@ -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:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。 + * + *

本任务(T2)按平台标准角色闸门落地,与 fork 模板 flow-center 的 + * {@code FlowAdminGuard} 完全同口径:超管({@code type==1})可跨校、且是唯一能操作 + * 平台级行({@code sch_id=0}:平台级表单/平台级字典,方案 §6.5、契约 §2.1)的身份; + * 校级管理员(roleId∈{2 校领导,5 局领导,7 管理员})限本校。

+ * + *

缺口记账:方案 §6.1 的第②层"模块授权(表单设计/字典管理模块行)"要把把关 + * 从角色换成授权模块集,依赖 GetUserMod 的模块树消费(T4 的 customId 扫描与 T8 的 + * 前端把关同一条链路),本轮不在此实现 → 问题单登记。

+ */ +@Service +public class FormAdminGuard { + + private static final int SUPER_ADMIN_TYPE = 1; + private static final List 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()); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java new file mode 100644 index 0000000..9be3ffd --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -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)。 + * + *

版本模型(V7 的落点):{@code form_version} 行一旦发布即逐字节冻结,改版=新增行。 + * 唯一的两种"写已存在版本行"的通道是:①未发布草稿行的直改(schema_json/change_log); + * ②发布动作补 {@code publish_time}。两者都不触碰已发布内容。

+ * + *

草稿与发布并存:已发布表单保存新 schema 时开 {@code version=max+1} 的草稿行, + * 头表 {@code current_version}/{@code status} 不动——于是升版瞬间在途填报仍按旧版走 + * (契约 §5"按加载版本校验钉版"天然成立),发布动作才把指针推过去。

+ * + *

表单级配置(audience/fill_limit/deadline/answer_no_enabled/status_defs/group_tag) + * 是头表列、原地可改、不进版本快照(方案 §5.1/§5.4)。{@code name} 也只在头表, + * 所以改名不升版。

+ */ +@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 list(Long schId, String appCode, Integer status) { + List rows = formRepository.findLiveBySch(schId); + List 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 draftVersions = draftVersions(filtered); + List 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 versions(Long schId, String uuid) { + Form form = requireForm(schId, uuid); + List 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) 解析当前发布版本, + * 域=会话校 ∪ 平台级(sch_id=0)且本校遮蔽平台——读接口的唯一入口。 + * 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); + } + + /** + * 保存(设计器"保存"): + *
    + *
  • 无草稿行 → 在草稿行上直改(未发布内容可反复改,V7 只冻结已发布行);
  • + *
  • 已发布过且无草稿行 → 开 {@code max+1} 新行,头表指针与状态不动(在途提交按旧版,契约 §5);
  • + *
  • {@code schemaJson} 为空 → 只改头表元数据(name/code),不产生版本行——改名不升版;
  • + *
  • code 仅"从未发布"时可改(契约 §1),改时同步草稿行冗余 code(uk_form_ver 含 code)。
  • + *
+ */ + @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 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 draftVersions(List forms) { + Map result = new HashMap<>(); + if (forms.isEmpty()) { + return result; + } + List 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 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 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 toStatusDefViews(String statusDefsJson) { + List 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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormDictService.java b/src/main/java/com/yunhai/formcenter/service/FormDictService.java new file mode 100644 index 0000000..86fbb4d --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FormDictService.java @@ -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)。 + * + *

三条不变式,全部是"字典机制能安全存在"的前提:

+ *
    + *
  1. value 不可变、label 可改:答案存 value,字典改名不打穿历史答卷(V38/D11 同款); + * 实现上 value 即条目的定位键(uk(dict_id,value)),列映射 {@code updatable=false} 物理挡改写。
  2. + *
  3. 软删条目的 value 仍通过提交校验({@link #allowedValues})——旧答卷可原样重存; + * 仅设计器不可新选({@link #inlineOptions} 只出未删条目)。
  4. + *
  5. 活引用、不进版本快照:{@code form_version.schema_json} 里存的是 + * {@code {"source":"dict:{code}"}},只在读路径展开({@link #expandSchema})。 + * 字典本身不进对外契约,消费方不需要二次请求字典接口。
  6. + *
+ * + *

两级读写口径必须分开:管理按同层精确匹配(校级改不到平台级、平台级不归学校管, + * 方案 §6.5);解析按域 {@code sch_id ∈ {会话校, 0}} 且本校遮蔽平台(契约 §2.1)。

+ */ +@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 listOwn(Long schId) { + List 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 listSelectable(Long schId) { + Map 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 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); + } + + /** 软删条目:设计器不再可选,提交校验仍放行(契约 §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 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 用):含软删条目的 value 全集——旧答卷原样重存的依据(V38)。 + * 字典解析不到时返回空集(调用方据此拒绝,而不是放行任意值)。 + */ + public Set allowedValues(Long schId, String dictCode) { + Set 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 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 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 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 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; + } +} diff --git a/src/test/java/com/yunhai/formcenter/common/FormRuntimeRulesTest.java b/src/test/java/com/yunhai/formcenter/common/FormRuntimeRulesTest.java new file mode 100644 index 0000000..3a3cb15 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/common/FormRuntimeRulesTest.java @@ -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(); + } +} diff --git a/src/test/java/com/yunhai/formcenter/common/FormSchemaValidatorTest.java b/src/test/java/com/yunhai/formcenter/common/FormSchemaValidatorTest.java new file mode 100644 index 0000000..b3126fe --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/common/FormSchemaValidatorTest.java @@ -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 校验单测(自验项 V2:options 非 {label,value} 被拒; + * V3:白名单外属性被拒、未声明 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 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(); + } +} diff --git a/src/test/java/com/yunhai/formcenter/common/FormStatusDefsTest.java b/src/test/java/com/yunhai/formcenter/common/FormStatusDefsTest.java new file mode 100644 index 0000000..2920b2d --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/common/FormStatusDefsTest.java @@ -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; + +/** + * 状态定义单测(自验项 V12:单表单可自定义状态集合,默认含"待处理/已处理")。 + * 顺带钉住 V13 的存储前提:删状态=软删定义,定义行不物理删,历史 code 仍可回显名。 + */ +@DisplayName("FormStatusDefs - 轻状态位(方案 §5.3)") +class FormStatusDefsTest { + + @Test + @DisplayName("V12:未配置时用默认集合,且默认就是待处理/已处理") + void defaultSetIsPendingAndProcessed() { + List 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 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(); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java b/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java new file mode 100644 index 0000000..e4f450b --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FormCopyIT.java @@ -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; + +/** + * 表单复制=设计器"另存为副本"(自验项 V41,方案 §5.4 v1.3 定一期): + * 副本=新 code / 新 uuid / version 从 1 起、schema 深拷贝、字典引用随行; + * 原表单与其历史数据零改动(本方法对源表只读,测试把源快照逐字段比回去)。 + */ +@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 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; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java b/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java new file mode 100644 index 0000000..ff4f3fa --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FormDefServiceIT.java @@ -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; + +/** + * 定义域版本语义集成测试(打真实 MySQL:本地 8.0.45 / 库 bsch_formcenter, + * {@code ddl-auto=update} 建表——CI 不跑单测,本地 {@code mvn test} 即门禁,任务书 §0.4)。 + * + *

覆盖自验项 V6(同 sch_id 同 code、不同 app_code 共存不冲突)、 + * V7(已发布版本无法就地修改;改版产生 version+1 新行;老版本 schema_json 逐字节不变), + * 以及契约 §1 的 uuid/resolve 校域遮蔽、方案 §5.4 的生命周期与"配置不进版本快照"。

+ */ +@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 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:草稿不暴露;发布后可解析;本校遮蔽平台级且不回落;未发布/停用给业务级错误") + 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; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java b/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java new file mode 100644 index 0000000..ce64359 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FormDictServiceIT.java @@ -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)。 + * + *

V2 的后半句"改 label 不改 value 后,历史答卷筛选与分组统计结果不变"在字典侧的 + * 依据就在这里:value 是答案存储值、label 只是展示名;再加两条字典独有的不变式—— + * 软删条目仍通过提交校验(V38)与活引用不进版本快照。

+ */ +@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 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 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 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(); + } +}