build / build-push (push) Successful in 1m46s
同时补记 CI 取证方式(本地凭据调 Actions API,凭据不回显不落盘)与 local-debug 目录不在两仓工作树内的事实。
130 lines
20 KiB
Markdown
130 lines
20 KiB
Markdown
# 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 | `03f6a97`(26 文件,全为新增,未触碰 T1 已验收文件) | `5c5abe1..03f6a97 main -> main` | [run 1468](http://192.168.10.160:3000/form-center/form-center-service/actions/runs/1468)(`build.yml@refs/heads/main`,head_sha `03f6a97…`,run_number 3) | **status=completed / conclusion=success**(Gitea Actions API 轮询实测:poll1~3 in_progress → poll4 completed+success) |
|
||
| form-center-front | 无改动 | 未推送 | — | T2 是纯后端任务,front 仓零变更(T5/T6 范围) |
|
||
|
||
- 推送方式:常规 `git push origin main`(T1 采用 clone 建仓,注入提交已在历史,无需 `--allow-unrelated-histories`);
|
||
未使用 `git reset`;暂存**按显式路径逐个 `git add`**(26 条路径,见提交前 `git status` 核对:无 `add -A`、无敏感文件)。
|
||
- CI 状态取证方式:`git credential fill` 取本地已存凭据后调
|
||
`GET /api/v1/repos/form-center/form-center-service/actions/runs[/{id}]`,凭据不回显、不落盘、不入库。
|
||
注意 CI 只跑 `mvn -B -DskipTests clean package`(**不跑单测**,方案 §7.2),所以 CI 绿只证明产物链;
|
||
定义域的行为证据在本文 §2 的本地 `mvn test`(67 条)里。
|
||
- 本轮工作树收尾:`git status --short` 干净,与 origin/main 推平。
|
||
- 本节的回填本身是一次 docs-only 提交(只改本文,不动任何构建输入),其后同流水线再跑一次。
|
||
|
||
—— 全文完 ——
|