diff --git a/docs/T3-report.md b/docs/T3-report.md new file mode 100644 index 0000000..d095892 --- /dev/null +++ b/docs/T3-report.md @@ -0,0 +1,139 @@ +# T3 执行说明 · 后端·答卷核心 + +> 任务书:《表单中心任务书-Qoder-20261014.md》v1.1 §T3|执行日期:2026-10-14|执行方:Qoder +> 输入权威:方案 v1.4(§4.3②/§5.2/§5.4/§7.7)+ 契约 v1.0(§3/§5/**§2.2 实现期补充定稿**/§6 **错误码四段定稿**/§10)+ 手册 §5 裁决 **P5/P10/P12/P14/P18/P19/P20/P21/P22** +> 前置:T1/T2 已验收。本轮对 T2 已验收文件只做**三处裁决驱动的扩展**(见 §1.6),未重构其行为;T2/T1 的 67 条测试原样通过。 + +--- + +## 1. 做了什么 + +### 1.1 新增文件(答卷域:6 实体 + 6 仓库 + 4 服务 + 2 控制器 + 1 校验器 + 1 DDL 脚本 + 7 测试类) + +| 包 | 新增 | 承担 | +| --- | --- | --- | +| `entity` | `Answer` / `AnswerIndex` / `StatusLog` / `AnswerOpLog` / `FillQuota` / `FormFile` | 契约 §10 答卷域五表(`MEDIUMTEXT`、`v_str(191)`、`DECIMAL(20,6)`、`uk_answer_uuid`/`uk_answer_idem`/`uk_quota` 逐项对齐)+ 附件落档行(P24) | +| `repository` | 六个同名 `*Repository` | 全部 JPQL/派生查询,**零 native SQL**(P10);`AnswerRepository`/`FillQuotaRepository` **不提供任何物理删除方法**(P22/V30) | +| `common` | `FormValueValidator` | 契约 §3 值形状 ×11 放行类的提交期校验;附件值只认 `{id,fileName,key}` 且拒绝对 URL;字典候选由服务层注入(保持可脱库单测) | +| `service` | `AnswerService`(提交编排)/ `AnswerIndexService`(索引写/重建/清理)/ `AnswerAdminService`(编辑/软删/改状态/批量)/ `FormFileService`(上传) | 见 §1.2–§1.4 | +| `model` | `AnswerModels` | 提交/编辑/批量/上传出入参;请求体**没有** `schId/submitterId` 字段,响应只出现 `answerId`(uuid) | +| `controller/api` | `FormAnswerController`(2 端点)+ `FormAnswerAdminController`(5 端点) | 见 §1.5 | +| `sql` | `t3_answer_domain.sql` | 答卷域六表 MySQL 5.7 兼容 DDL(评审权威 + 生产 `ddl-auto=none` 升级底稿) | +| `local-debug` | `t3-answer-smoke.sh` | 手册 §4.2 | + +### 1.2 提交链路(契约 §5;顺序即语义,写在 `AnswerService` 类注释里) + +``` +钉版解析 → 截止 → 幂等命中 → subject 授权 → 值校验(按钉住版本) → 附件同校归属 + → fill_quota 占位(撞 uk 即拒) → 落 answer → 写 answer_index(只写 searchable) +``` + +三处顺序是有意为之,不是随手排的: + +1. **幂等在限次之前**:重放的那一条本来就占着名额,先查限次会把"双击重试"误判成"超过次数"; +2. **限次用"事务内插入 + uk 撞键即拒"**(方案 §5.4 v1.4),不做"先 count 再插"——count 在并发下靠不住; + `fill_quota` 与 `answer` 同事务,答卷插入失败则名额一起回滚,不留"名额被占但无答卷"的脏状态; +3. **值校验用未展开的 schema 原文 + `allowedValues`(含软删条目)**,而不是 resolve 的内联结果: + 展开后就看不出字段引用了哪个字典,"软删条目仍放行"(契约 §2.1/V38)无法表达。 + +**钉版**:`FormDefService.resolvePublished` 与填报页 resolve 走**同一条遮蔽实现**(本轮把它从 `resolve()` 里提出来复用),提交时解析到的 `current_version` 即加载版;配合 P20(升版只开草稿行、指针不动),"升版瞬间在途提交按旧版安全落地"天然成立。请求体**不接收** version(契约 §5 请求字段是闭集)。 + +**身份**(V17):`sch_id`/`submitter_id` 只从会话 `BoaUser` 取,`SubmitRequest` 里没有这两个字段(客户端即便多传也被 Jackson 忽略);`subject_id` 非空且 ≠ 本人 → 要求管理权(P21 闸门),否则 FORBIDDEN。 + +### 1.3 索引与状态(方案 §5.2/§5.3) + +- `AnswerIndexService`:只写显式 `searchable` 字段;checkbox/multiselect **一值一行**;`number→v_num`、`date→v_date`、文本/选项 `→v_str(截 191)`;`upload/image` 无检索算子定义 → 不写行只 warn(P28)。 +- 三个调用点:提交写入 / 编辑 `rebuild` / 软删 `clear`。**不存在**"按新版本 schema 清理旧字段行"的入口——那会打掉 V8 的历史可筛。 +- 初始状态 = 表单 `status_defs` 里第一个未废弃 code(未配置即 `pending`)。 + +### 1.4 上传模块(平台标准版,未抄 notice FileController) + +- 存储根读取形 `${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}`(yml 注释里定死的那条链;启动日志打印 `store_root=`,IT 断言兜底链生效);**yml 的 notice 教训注释一字未动**。 +- `key = form/yyyy/MM/{SecureRandom 32hex}.{ext}`:存储名与用户文件名完全无关 → 同名不覆盖、路径穿越被吸收成展示名、伪装双扩展名按最后一段判定。 +- 黑名单 = 平台同源 15 项(jsp/jspx/html/htm/js/exe/sh/bat/php/asp/aspx/jar/war/class/htaccess)**+ `svg`**(P27);扩展名形态限 `^[a-z0-9]{1,15}$`。 +- 返回值字段闭集 `{id, fileName, key}`;库里也只存相对 `store_key`(varchar(191),本列有唯一键,避开 5.7 的 767 字节单列索引上限)。 +- **P12 已配**:`spring.servlet.multipart.max-file-size/max-request-size=50MB`(默认 1MB 会直接挡掉合法附件),服务侧再判一次 ≤50MB(容器上限与业务上限分离,报错信息可读)。 + +### 1.5 接口清单(本轮 7 个新端点,累计 28) + +- 用户面:`POST /api/form/submit`(幂等命中仍 200+原 `answerId`)、`POST /api/form/upload`(单文件)。 +- 数据管理面 `POST /api/form/admin/data/*`:`edit`、`softDelete`、`changeStatus`、`batch/softDelete`、`batch/changeStatus`。 +- **本轮刻意不建**:列表/详情/统计/导出读接口——方案 §6.4 红线要求四条读出路径共用同一个 scope 构造器,那是 T4 的建成物;先写一个无范围过滤的详情接口就是给 T4 留越权后门。 +- **也不存在**填表人可用的编辑/撤回端点(V29 的"404/403"里,404 由端点不存在保证、403 由守卫保证)。 + +### 1.6 对 T2 已验收产出的三处改动(全部裁决驱动,非重构) + +| 文件 | 改了什么 | 依据 | +| --- | --- | --- | +| `FormAdminGuard` | 校级角色集 `{2,5,7}` → **`{2,7}`**;新增 `canManage()`(不抛异常版,供 subject 授权分支);平台级拒绝判定前移(原因说准) | **P21 收紧**(局领导属查看角色,不给定义/数据管理权)+ 契约 §5 代填分支 | +| `FormDefService` | `resolve()` 抽出 `resolvePublished()`(返回头表+版本行,供提交钉版复用同一条遮蔽实现);新增 `pinnedForData()`(数据面解析,不校验生命周期、含软删表单、遮蔽歧义时退平台级行);`answerNoEnabled=1` 时校验 `code ≤49`(`answer_no varchar(64)` 装得下号) | 契约 §5 钉版、§5.4 数据读改不受停用/软删影响、§10 列宽 | +| `FormRepository` | 新增 `findDataDomainCandidates`(不排除软删表单的取域查询) | 方案 §5.4"软删是唯一切除形态、无物理删除"→ 答卷溯源不能断 | +| `application.yml` | 只加 `spring.servlet.multipart` 两行 | **P12** | + +### 1.7 环境动作(本机副作用) + +- 复用本地 MySQL 8.0.45(3306)+ 库 `bsch_formcenter`;`ddl-auto=update` 新建答卷域六表(`form_file.store_key` 后由 255 手工 `ALTER` 到 191 以对齐实体,表当时为空,无数据风险)。 +- 为验证 `sql/t3_answer_domain.sql` 可执行,临时建库 `fc_t3_ddlcheck` 实跑(`mysql_exit=0`、6 表齐),**验证完已 DROP**,`SHOW DATABASES LIKE 'fc_t3%'` 为空。 +- 上传测试写入 `service/target/tmp-upload/`(`mvn clean` 带走;`target/` 已被 .gitignore 排除),**未写 `D:/data/upload`**——冒烟启动时未注入 env,兜底键生效才写到那里,本轮该路径下只有脚本外无手工写入(T1 起该目录即本地上传兜底根)。 +- IT 数据零残留:`answer/answer_index/fill_quota/answer_op_log/status_log/form_file/form` 计数全为 0(并发 IT 非回滚,靠 `@AfterEach` 定点清理,已验证)。 +- 未启动 CAS/UserBase/Redis;未触碰 160 共享环境。 + +--- + +## 2. 自验证据(逐 V) + +**门禁**:`mvn -B -o clean package` → **BUILD SUCCESS,Tests run: 121, Failures: 0, Errors: 0, Skipped: 0**,产物 `target/form-center.jar`。 +其中 **T3 新增 54 条**(`FormValueValidatorTest` 12 + `FormAdminGuardTest` 5 + `AnswerSubmitIT` 17 + `AnswerAdminIT` 11 + `FormFileServiceIT` 7 + `AnswerConcurrencyIT` 2),**39 条打真实 MySQL**;T2 的 67 条一条未改仍全绿。 + +| V | 断言(方案 §9 原文) | 证据 | +| --- | --- | --- | +| **V17** | `submitter_id`/`sch_id` 由会话推导、接口不接受外部传入;越权构造被拒;**普通用户把 `subject_id` 构造成他人被拒(仅可=自己,管理员代填不受此限)** | `AnswerSubmitIT.subjectAuthorization`(构造成他人→"无权为他人填报";=自己→放行且 `submitter=subject`)、`adminCanFillOnBehalf`(管理员代填:`submitter=教师`、`subject=学生`两键分列)、`sessionRequired`(未登录/无学校归属各自被拒)、`idempotencyKeyOwnedByAnotherAccountIsRejected`(跨账号撞库级幂等键不给他人 answerId);权限层 `FormAdminGuardTest`(5 条,含 P21 的 roleId=5 被移出)。请求体无 `schId/submitterId` 字段=结构性不可能外部传入 | +| **V29** | 填表人无编辑/撤回接口可用(404/403);管理员编辑成功且写 `answer_op_log` | 404:`FormAnswerController` 只有 `submit`/`upload` 两个端点(结构即证据)+ `FormAnswerAdminController` 的守卫;403:`FormAdminGuardTest.onlyRoleIdsTwoAndSevenManage`。`AnswerAdminIT.adminEditWritesOpLogAndRebuildsIndex`:编辑成功、`values_json` 更新、`op_log` 恰一行 `op=edit`+操作人正确、**身份列与状态列不动**、索引按钉住版本重建(`v_num` 变成 88);`editStillEnforcesValueContract` 证明被拒时**不落** op_log、原值不被破坏 | +| **V30** | 软删后列表/详情/统计/导出均不可见,索引行同步删除;无物理删除接口存在 | `AnswerAdminIT.softDeleteIsTheOnlyDeletion`:`is_deleted=1`、`answer_index` 行清空、写 `op_log`、`answerRepository.findById` 仍在(物理行还在)、重复删被拒。"四读出路径不可见"依赖 T4 读接口(本轮刻意不建,见 §1.5);**无物理删除**=两个仓库都不提供 delete 方法、控制器无端点(`AnswerRepository` javadoc 成文)。`batchSoftDeletePerItemLogging` 覆盖批量同口径 | +| **V31** | 同幂等键重复提交只产生一条答卷 | `AnswerSubmitIT.idempotencyReturnsOriginalAnswer`:两次同键 → 库里 1 行、第二次 `answerId` 相同且 `replayed=true`、**原值不被第二次的值覆盖**;`idempotencyKeyRequired`(必填/≤64/不同键各成一条);并发同键见 V32 | +| **V32** | 限一次:subject 非空按 subject 限(两老师代填同一学生第二条被拒)、subject 空按 submitter 限(同人第二次被拒)——**两种 limitScope 各验一条**;**并发同键双提交恰一条成功(fill_quota 唯一键兜底)**;**软删后可重填(名额已释放)**;不限次表单不受影响 | 功能面:`onceLimitIsPerSubjectWhenSubjectPresent`(**两个不同教师**代填同一学生→第二条"限填一次";换学生仍可填)、`onceLimitFallsBackToSubmitter`(同人第二次被拒 + 不限次表单两条都过)、`softDeleteReleasesQuota`(软删后同 subject 可重填)。**并发面**:`AnswerConcurrencyIT.concurrentSubmitsAgainstOnceLimitProduceExactlyOneAnswer`(真 MySQL 双线程同时放开:恰 1 条 `answer`、恰一条成功另一个拿到"限填一次"、`fill_quota` 恰 1 行 `limit_key=提交人id`、成功那条索引照常);`concurrentSubmitsWithSameIdempotencyKeyProduceOneAnswer`(同幂等键并发:恰 1 行,两个返回值指向同一 uuid——即"撞 uk 后回读原答卷"的并发结局) | +| **V33** | 截止后填报页只读、提交接口拒绝;停用表单入口失效但数据管理可见 | `deadlineBlocksSubmit`(2020 年截止→"已过收集截止时间";改 2099→放行,顺带证"只选日期补 23:59:59")、`disabledFormClosesFillEntryOnly`(停用后提交被拒"已停用",`enable` 后同表单同数据恢复可填)、`AnswerAdminIT.domainScopingAndSoftDeletedFormStillOwnsAnswers`(表单软删后其存量答卷仍可编辑=数据侧可见可管理) | +| **V14**(编排补充①) | 填表人改状态被拒(接口层) | `AnswerAdminIT.submitterCannotChangeStatus`:对学生(3)/教师(1) 身份调控制器实际走的 `adminGuard.requireAdmin` → FORBIDDEN"无表单管理权限",对管理员放行;数据面不存在"我改我这条的状态"的端点 | +| **V15**(编排补充①) | 改状态写 `status_log`;系统不存在任何流转规则校验 | `statusChangeHasNoTransitionRules`:`pending → back → pending → done`(含倒退)全部放行,`status_log` 三行按序、操作人正确;`statusMustBeLiveDefinition` 证明唯一约束是"目标值必须是未废弃定义",**不是**转移规则 | +| **V24**(编排补充②,本地可断言部分) | 非白名单扩展名被拒;答卷值中不含绝对 URL;返回只含 `{id,fileName,key}` | `FormFileServiceIT.dangerousExtensionsRejected`(15 项逐个 + `svg` + 缺扩展名 + 非法扩展名形态 + `report.pdf.jsp` 伪装双扩展名 + 放行 `ok.docx`)、`uploadReturnsContractTripleAndStoresUnderRoot`(响应字段闭集恰是三件、`key` 匹配 `^form/\d{4}/\d{2}/[0-9a-f]{32}\.jpg$`、库里也存相对 key)、`fileSizeCap`(>50MB 拒且不留行;空文件拒)、`submittedAttachmentIdsMustBeOwned`(提交值里 `key` 给 `http://cdn...` 被拒"相对存储路径";伪造附件 id 被拒)。**"经 FILE_WEB_PATH 可访问"按编排留给 T9 部署环境**;`FILE_PATH` 兜底链由 `storeRootFollowsDocumentedPlaceholderChain` 断言 | +| **V1/V3/V8/V9 相邻面** | 值形状按契约 §3 全类型接受;索引只写 searchable;升版字段身份 | `FormValueValidatorTest` 12 条(含 14 类里 11 类的示例值、附件形状、字典软删放行、上限 9);`AnswerSubmitIT.indexWritesSearchableOnly`(`remark` 不声明就不进索引、250 字符截到 191、`v_date`/`v_num` 类型正确)、`submitPinsCurrentlyPublishedVersion`(v2 字段在 v1 期被拒"未知字段",发布后按 v2 钉版) | + +**其它取证** + +| 项 | 结果 | +| --- | --- | +| MySQL 5.7 复核(P10) | ①答卷域**零 native SQL**;②`sql/t3_answer_domain.sql` 在 `fc_t3_ddlcheck` 实跑 `exit=0`、6 表齐、随后 DROP;③`SHOW CREATE TABLE` 逐列对齐契约 §10(`mediumtext`/`v_str(191)`/`decimal(20,6)`/`date`/两个 uk + `uk_quota` + 四个 answer_index 复合索引);④`form_file.store_key` 由 255 主动收到 **191**(该列有唯一键,避开 5.7 单列索引 767 字节上限);⑤answer_index 复合索引键长约 1292B,依赖 5.7 默认 `ROW_FORMAT=DYNAMIC`+`innodb_large_prefix=ON` → 记 **P31** 待 160 核 | +| 启动冒烟 | `bash local-debug/t3-answer-smoke.sh` → **7/7 PASS**:health=200;`store_root=D:/data/upload`(兜底链生效);`/api/form/submit`、`/api/form/upload`、`/api/form/admin/data/edit`、`.../batch/changeStatus` 无票均 302→CAS;`/oapi/*` 仍 401 | +| 错误码口径(P14 定稿) | 全线沿用四段:值形状/未知字段/附件伪造/未找到=`PARAM_ERROR(2)`;未登录=`UNAUTHORIZED(4)`;subject 越权/无管理权=`FORBIDDEN(3)`;限次/截止/未发布/已停用/无草稿=`CUSTOM(5)`。本轮未引入任何 VALIDATE/NOT_FOUND 字样 | +| 登录态冒烟(P1 遗留) | 仍无本地 CAS/UserBase,接口以"墙后 302 + 54 条服务层直连真库"替代取证;缺口沿 P1 归 T5 联调轮 | + +--- + +## 3. 问题单(本轮登记,供执行手册 §5 裁决;编号接续 P22) + +| # | 问题 | 本轮取值(最保守解读) | 影响面 | +| --- | --- | --- | --- | +| P23 | 契约 §10 `uk_answer_idem(idempotency_key)` 是**库级**唯一键(不含 `sch_id`/`submitter_id`),跨账号撞同键的语义契约未写 | 命中同键且归属(会话校+提交人)一致 → 200+原 `answerId`;**归属不一致 → FORBIDDEN**(不把他人 answerId 回给请求方,uuid 不可枚举但同键命中本身就是探测面);并发撞键由编排在事务外回读原答卷。若裁决要"键只需表单内唯一",把 uk 改 `(sch_id, code, idempotency_key)` | 幂等语义与并发测试 | +| P24 | 契约 §3 附件值形状含 `id`,但 §10 九表**没有附件表**——`id` 无处解析 | 建最小 `form_file`(`id/sch_id/file_name/store_key/file_size/ext`,`uk(store_key)`),上传成功即落档;值里的 `id` 即本表主键,提交时做**同校归属校验**。若裁决"中心不落附件档、`id` 由消费方自造",本表与归属校验一起退掉 | 上传模块 + 二期 GC/鉴权下载接口 | +| P25 | 契约 §1 校域不变式使 `answer.sch_id` 恒=会话校,于是答卷的钉版坐标 `(校, app_code, code, version)` **不含"表单归属校"**:平台级表单被本校后建的同 `(app_code,code)` 表单遮蔽后,历史答卷版本行解析有歧义窗口 | `pinnedForData()` 在本校域取不到该版本行时,**退回平台级同 code 表单的行**(历史数据仍按原 schema 校验,不静默失败)。根治=`answer` 增列 `form_uuid`(或契约明确"平台级表单答卷必须存平台级归属") | 数据面溯源;T4 读详情/导出同样要走这条解析 | +| P26 | `answer_no` 当日序号取"当日行数+1",**并发下可重号**;契约 §10 未给 `answer_no` 任何唯一键 | 按 DDL 草案执行:不加索引、不改格式,序号含软删行(软删不回收号)。要根治需 `uk(sch_id, code, answer_no)` + 撞号重试,或独立取号表——属加语义,等裁决 | 报名号场景(第一天就会并发);T9 压测可暴露 | +| P27 | 上传黑名单:平台同源清单(svc-hall/UserBase/Notice 那 15 项)不含 `svg`,而一期"key=不可猜测随机串即访问控制"(方案 §7.7)对 svg 失效(同源直访 + 内嵌脚本=存储型 XSS 面) | **在平台 15 项上追加 `svg`**,代码注释写明理由。若确有图标上传需求,需二期的鉴权下载接口(U11 族)才能安全放行 | 安全边界;前端 T7/T8 若允许 svg 需同步 | +| P28 | 附件字段勾了 `searchable` 时如何建索引,契约 §2 未给附件的检索算子(算子只定义了 等值/区间/前缀 三类) | **不写索引行**并 warn(不发明算子、不拿 fileName 冒充可筛值)。若要"N 个文件"可筛,需先定算子 | V3/V9 一致性;T4 筛选候选列表 | +| P29 | 批量操作单次条数上限,方案 §5.4 只写"批量逐条走单条校验与留痕" | 加安全阈值 **200**(逐条各走独立事务,一次请求挂几千条等于把接口变成 DoS 面);超限 `PARAM_ERROR`。批量与单条同形:`op` 用 `batch_*` 前缀(契约 §10 注释的 `edit/soft_delete/batch_…` 形态)、一条答卷一行日志、summary 带批次上下文 | 前端 T8 分页勾选即可满足 | +| P30 | 数据管理操作的行级范围(契约 §8 矩阵 / `fc:scope:all`)本轮未接 | 仍走 P21 过渡角色闸门 + `(域, uuid)` 定位(跨校答卷直接查不到);**行级 scope 与"编辑入口按数据范围收敛"归 T4**(V27/V39 的断言点)。本轮不做"半个 scope",避免与 T4 的构造器双份实现 | T4 收紧点 | +| P31 | `answer_index` 三条复合索引键长约 1292 字节,超过 5.7 旧行格式的单索引 767 字节上限 | 依赖 5.7 默认 `ROW_FORMAT=DYNAMIC` + `innodb_large_prefix=ON`(上限 3072);`sql/t3_answer_domain.sql` 顶部把该前提与降级方案(`v_str` 降 `varchar(64)`,前缀匹配语义不变)写死。**160 生产行格式/版本待核**(P10 的延伸,方案 §7.7 记档项) | 建表能否成功;T9 部署前必查 | +| P32 | 编辑/改状态是否受"截止时间"与"表单软删"约束,契约只写了填报入口 | 编辑与改状态**不看截止**(截止只挡填报,方案 §5.4);表单软删后其存量答卷**仍可管理**(数据面取域不排除软删表单,新增 `findDataDomainCandidates`)。两条都按"停用/软删只断填报入口、数据读改不变"外推 | T4/T8 读路径需保持同一外推口径 | + +--- + +## 4. 推送与 CI + +| 仓 | 提交 | 推送 | CI run | 结论 | +| --- | --- | --- | --- | --- | +| form-center-service | 待回填 | 待回填 | 待回填 | 待回填 | +| form-center-front | 无改动 | 未推送 | — | T3 是纯后端任务 | + +> 纪律:常规 `git push origin main`;`git add` 按显式路径逐个添加(不用 `add -A`);CI 只跑 `mvn -B -DskipTests`, +> 所以 CI 绿只证明产物链,行为证据在本文 §2 的本地 `mvn test`(121 条)里。 + +—— 全文完 —— diff --git a/sql/t3_answer_domain.sql b/sql/t3_answer_domain.sql new file mode 100644 index 0000000..0e2f2be --- /dev/null +++ b/sql/t3_answer_domain.sql @@ -0,0 +1,120 @@ +-- ============================================================ +-- 表单中心 T3 · 答卷域 DDL(MySQL 5.7 兼容复核版) +-- 范围:契约 §10 九表中的答卷域五表 = answer / answer_index / status_log / answer_op_log / fill_quota +-- 另加 form_file(中心上传附件落档行;契约 §10 未列,但 §3 值形状含 id → 见 T3 报告问题单 P24) +-- 用途:评审权威 + 生产 ddl-auto=none 时的升级脚本底稿;开发期仍由 ddl-auto=update 建表。 +-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列;②utf8mb4(本脚本显式写 CHARSET/COLLATE); +-- ③InnoDB、无物理外键(方案 §7.7 逻辑外键约定);④TEXT/MEDIUMTEXT/LONGTEXT 一律不写 DEFAULT(5.7 不允许); +-- ⑤索引键长:单列 uk/普通列的 utf8mb4 上限 767 字节=191 字符(v_str、store_key 均按此定宽), +-- answer_index 三条复合索引键长约 1292 字节,依赖 5.7 默认 ROW_FORMAT=DYNAMIC + innodb_large_prefix=ON +-- (上限 3072)——160 生产 MySQL 版本与行格式待核(方案 §7.7/P10 记档项), +-- 若为 COMPACT 或 large_prefix=OFF,需把 v_str 降到 varchar(64)(前缀匹配语义不变,可筛选更短)。 +-- P5/P15 裁决:实体继承 BaseEntity,实表=契约草案 + create_by/update_by 两列; +-- 审计列在基类未标 nullable=false,故按建表实态写 DEFAULT NULL(Listener 恒写 now(),业务上不为空)。 +-- 本机取证:MySQL 8.0.45 / bsch_formcenter 由 ddl-auto=update 建表后 SHOW CREATE TABLE 逐列比对一致。 +-- ============================================================ + +CREATE TABLE `answer` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `uuid` VARCHAR(32) NOT NULL COMMENT '数据面运行时身份(详情 URL/嵌入地址/L2 引用,契约 §1)', + `sch_id` BIGINT NOT NULL COMMENT '恒=会话校(校域不变式:跨校提交在解析层即不可能)', + `app_code` VARCHAR(64) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `version` INT NOT NULL COMMENT '钉住的 schema 版本(提交时解析到的发布版,升版不回改)', + `submitter_id` INT NOT NULL COMMENT '谁提交的(CAS 会话推导,不可外部传入)', + `subject_id` INT DEFAULT NULL COMMENT '关于谁(代填时=被代填人;普通用户只能=自己)', + `status` VARCHAR(64) NOT NULL COMMENT '轻状态位 code,取值域=form.status_defs(一等列,方案 §5.3②)', + `values_json` MEDIUMTEXT NOT NULL COMMENT '值文档 {key:value}(TEXT 的 64KB 会被长文本答卷顶爆)', + `answer_no` VARCHAR(64) DEFAULT NULL COMMENT '报名号 {code}-{yyyyMMdd}-{当日序号},表单级开关默认关', + `idempotency_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键;库级唯一——防双击只有 DB 唯一索引靠得住', + `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_answer_uuid` (`uuid`), + UNIQUE KEY `uk_answer_idem` (`idempotency_key`), + KEY `idx_answer_form` (`sch_id`, `code`, `status`, `id`), + KEY `idx_answer_submitter` (`sch_id`, `submitter_id`), + KEY `idx_answer_subject` (`subject_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='答卷(方案 §5.1:定义与答卷物理分离)'; + +CREATE TABLE `answer_index` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `answer_id` BIGINT NOT NULL COMMENT '逻辑外键 → answer.id', + `sch_id` BIGINT NOT NULL, + `code` VARCHAR(64) NOT NULL, + `field_key` VARCHAR(64) NOT NULL COMMENT '只写显式声明 searchable 的字段(V3/V9)', + `v_str` VARCHAR(191) DEFAULT NULL COMMENT '文本/选项值;utf8mb4 下 767 字节索引上限,超长截断=前缀匹配', + `v_num` DECIMAL(20,6) DEFAULT NULL COMMENT '数值值(precision 白名单上界 6 与此同口径)', + `v_date` DATE DEFAULT NULL COMMENT '日期值(禁 TEXT 字典序比日期,方案 §4.3④/V4)', + `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`), + KEY `idx_ai_str` (`sch_id`, `code`, `field_key`, `v_str`, `answer_id`), + KEY `idx_ai_num` (`sch_id`, `code`, `field_key`, `v_num`, `answer_id`), + KEY `idx_ai_date` (`sch_id`, `code`, `field_key`, `v_date`, `answer_id`), + KEY `idx_ai_answer` (`answer_id`) COMMENT '软删同步删行用' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='索引值表:一 searchable 字段值一行(多值多行),同时支撑筛选与分组统计'; + +CREATE TABLE `status_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `answer_id` BIGINT NOT NULL, + `status_code` VARCHAR(64) NOT NULL, + `operator_id` INT NOT NULL COMMENT '操作人;填表人改状态在接口层即被拒(V14)', + `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`), + KEY `idx_sl_answer` (`answer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='状态留痕:只记"谁把这条标成什么",系统内不存在任何转移规则校验(V15)'; + +CREATE TABLE `answer_op_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `answer_id` BIGINT NOT NULL, + `op` VARCHAR(32) NOT NULL COMMENT 'edit/soft_delete/change_status/batch_soft_delete/batch_change_status', + `operator_id` INT NOT NULL, + `summary` VARCHAR(500) DEFAULT NULL COMMENT '改动摘要/批次上下文(批量逐条一行,与单条同形)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_ol_answer` (`answer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='操作留痕(方案 §5.4)'; + +CREATE TABLE `fill_quota` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `form_id` BIGINT NOT NULL COMMENT '限次是表单级(跨版本共享名额)', + `limit_key` VARCHAR(128) NOT NULL COMMENT 'coalesce(subject_id, submitter_id) 字符串化', + `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_quota` (`form_id`, `limit_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='限填登记:提交事务内插入、撞键即拒(并发兜底);软删答卷同步删行=释放名额'; + +CREATE TABLE `form_file` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL, + `file_name` VARCHAR(255) NOT NULL COMMENT '用户上传时的展示名(已剥路径分隔符与上跳)', + `store_key` VARCHAR(191) NOT NULL COMMENT '相对存储 key=form/yyyy/MM/{32hex}.{ext};不可猜测串即访问控制(方案 §7.7)。191 而非 255:本列有唯一键,utf8mb4 下单列索引 767 字节上限', + `file_size` BIGINT NOT NULL COMMENT '字节数(≤50MB 全局兜底在上传时判)', + `ext` VARCHAR(16) NOT NULL COMMENT '归一化小写扩展名(黑名单判定结果留档)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_form_file_key` (`store_key`), + KEY `idx_form_file_sch` (`sch_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='中心上传附件落档(值形状 {id,fileName,key} 里的 id 由本表提供;绝不存绝对 URL)'; diff --git a/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java b/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java new file mode 100644 index 0000000..c2a571a --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java @@ -0,0 +1,346 @@ +package com.yunhai.formcenter.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 提交值校验(契约 §3 值形状 + §5 校验条款;方案 §4.3②)。 + * + *
校验基准是答卷钉住的那个版本的 schema({@code answer.version}), + * 而不是表单的最新版本——升版窗口旧版仍可填(P20),新 schema 的字段不会凭空出现在旧数据上, + * 旧 schema 已删的字段也不会被新校验误拒(编辑回显同口径,方案 §5.4"编辑按钉住 version 校验")。
+ * + *与 {@link FormSchemaValidator} 的分工:那个管定义(发布期,白名单/类型/options 形状), + * 这个管数据(提交期,值形状与取值域)。两者都只用声明式规则, + * 严禁存库 JS / eval(旧 CustomForm 反面先例)。
+ * + *取值域(P18/P19 定稿):内联 options 的 value 集合 ∪ 字典条目的 value 集合, + * 其中字典侧必须走 {@code FormDictService#allowedValues}(含软删条目)—— + * 旧答卷原样重存的放行口径(契约 §2.1)。
+ */ +public final class FormValueValidator { + + /** 全局兜底:单文件 ≤50MB(方案 §7.7) */ + public static final long MAX_FILE_SIZE_BYTES = 50L * 1024 * 1024; + /** 全局兜底:每字段 ≤9 个附件(字段级 maxFiles 只能更严,不能放宽) */ + public static final int MAX_FILES_PER_FIELD = 9; + + private static final Pattern DATE_STRICT = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + /** uuuu + STRICT:拒绝 2023-02-30 这类"格式对但日期不存在"的值(yyyy 在 STRICT 下会被宽松处理) */ + private static final DateTimeFormatter DATE_PARSER = + DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); + + private static final Set这里没有编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用" + * 的实现形态就是不存在这些端点,加上管理权守卫给出的 403)。改状态同理:填表人在接口层即被拒(V14)。
+ * + *也不提供列表/详情读接口:那四条读出路径(列表/详情/统计/导出)必须共用同一个 scope 构造器 + * (方案 §6.4 红线,V27/V30),归 T4 一次建成,避免本轮先写一个没有范围过滤的读接口、T4 再补一遍。
+ * + *{@code schId} 参数只是超管跨校的目标域(校级传别的值即拒,同 {@link FormAdminController} 口径); + * 答卷定位按 {@code (域, uuid)},跨校答卷查不到。
+ */ +@RestController +@RequestMapping("/api/form/admin/data") +@RequiredArgsConstructor +public class FormAnswerAdminController { + + private final FormAdminGuard adminGuard; + private final AnswerAdminService adminService; + + /** 编辑值文档:按该答卷钉住的 version 校验(V8 同族),写 answer_op_log,索引同步重建 */ + @PostMapping("/edit") + public void edit(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody AnswerModels.EditRequest body) { + Long domain = adminGuard.requireAdmin(user, schId); + adminService.edit(domain, user.getId(), body); + } + + /** 软删(唯一删除形态,无物理删除接口——V30);同步删索引行并释放限次名额 */ + @PostMapping("/softDelete") + public void softDelete(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestParam("answerId") String answerId) { + Long domain = adminGuard.requireAdmin(user, schId); + adminService.softDelete(domain, user.getId(), answerId); + } + + /** 改状态:填表人不可用(V14);写 status_log,系统内无任何转移规则校验(V15) */ + @PostMapping("/changeStatus") + public void changeStatus(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody AnswerModels.ChangeStatusRequest body) { + Long domain = adminGuard.requireAdmin(user, schId); + adminService.changeStatus(domain, user.getId(), body); + } + + /** 批量软删:逐条单条校验与留痕,每条独立事务,结果逐条返回 */ + @PostMapping("/batch/softDelete") + public List契约纪律:
+ *三条钉死的事实:
+ *{@code values_json} 用 MEDIUMTEXT:TEXT 的 64KB 会被长文本答卷顶爆(方案 §5.1)。 + * 值形状见契约 §3,其中附件只存 {@code {id,fileName,key}}(key=相对路径,绝不含绝对 URL)。
+ */ +@Getter +@Setter +@Entity +@Table(name = "answer", + uniqueConstraints = { + @UniqueConstraint(name = "uk_answer_uuid", columnNames = "uuid"), + @UniqueConstraint(name = "uk_answer_idem", columnNames = "idempotency_key") + }, + indexes = { + @Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"), + @Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"), + @Index(name = "idx_answer_subject", columnList = "subject_id") + }) +public class Answer extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 数据面运行时身份(详情 URL / 嵌入地址 / L2 引用都用它,契约 §1) */ + @Column(name = "uuid", nullable = false, length = 32, updatable = false) + private String uuid; + + @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; + + /** 钉住的 schema 版本(提交时解析到的发布版本,之后不随升版改变) */ + @Column(name = "version", nullable = false, updatable = false) + private Integer version; + + /** 谁提交的(CAS 会话推导) */ + @Column(name = "submitter_id", nullable = false, updatable = false) + private Integer submitterId; + + /** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */ + @Column(name = "subject_id") + private Integer subjectId; + + /** 轻状态位:字符串 code,取值域=表单状态定义(一等列,方案 §5.3) */ + @Column(name = "status", nullable = false, length = 64) + private String status; + + /** 值文档 {@code {key:value}}(契约 §3 值形状) */ + @Column(name = "values_json", nullable = false, columnDefinition = "mediumtext") + private String valuesJson; + + /** 报名号 {@code {code}-{YYYYMMDD}-{当日序号}};表单级开关默认关(契约 §5) */ + @Column(name = "answer_no", length = 64) + private String answerNo; + + /** 客户端幂等键(库级唯一) */ + @Column(name = "idempotency_key", nullable = false, length = 64, updatable = false) + private String idempotencyKey; + + /** 软删=唯一删除形态(无物理删除接口,P22/V30);同步删 answer_index 行、释放 fill_quota 名额 */ + @Column(name = "is_deleted", nullable = false, columnDefinition = "tinyint") + private Integer isDeleted = 0; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerIndex.java b/src/main/java/com/yunhai/formcenter/entity/AnswerIndex.java new file mode 100644 index 0000000..33967b3 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AnswerIndex.java @@ -0,0 +1,77 @@ +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 java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 索引值表(契约 §10 / 方案 §5.2):一答卷的一个 searchable 字段值一行, + * 同时支撑筛选与分组统计;零 DDL 加字段、类型正确({@code v_date} 区间、{@code v_num} 数值)。 + * + *四条纪律:
+ *列选择按该答卷钉住版本的 schema 判定:只有该版本声明 searchable 的字段才有行。
+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_index", + indexes = { + @Index(name = "idx_ai_str", columnList = "sch_id, code, field_key, v_str, answer_id"), + @Index(name = "idx_ai_num", columnList = "sch_id, code, field_key, v_num, answer_id"), + @Index(name = "idx_ai_date", columnList = "sch_id, code, field_key, v_date, answer_id"), + @Index(name = "idx_ai_answer", columnList = "answer_id") + }) +public class AnswerIndex extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** utf8mb4 下 InnoDB 索引列上限 767 字节 = 191 字符(契约 §10 定死) */ + public static final int MAX_V_STR_LEN = 191; + + @Column(name = "answer_id", nullable = false, updatable = false) + private Long answerId; + + @Column(name = "sch_id", nullable = false, updatable = false) + private Long schId; + + @Column(name = "code", nullable = false, length = 64, updatable = false) + private String code; + + @Column(name = "field_key", nullable = false, length = 64, updatable = false) + private String fieldKey; + + /** 文本/选项类:等值与前缀模糊;超长截断 */ + @Column(name = "v_str", length = MAX_V_STR_LEN) + private String vStr; + + /** 数值类:区间与分组统计;DECIMAL(20,6) 与 precision ≤6 的白名单约束同口径 */ + @Column(name = "v_num", precision = 20, scale = 6) + private BigDecimal vNum; + + /** 日期类:{@code YYYY-MM-DD} 落 DATE 列(禁字典序比日期,方案 §4.3④/V4) */ + @Column(name = "v_date") + private LocalDate vDate; + + /** 超长截断只在入索引这一处发生,值文档本身不动(三态空值与降级显示读的是 values_json) */ + public static String truncateForIndex(String value) { + if (value == null) { + return null; + } + return value.length() <= MAX_V_STR_LEN ? value : value.substring(0, MAX_V_STR_LEN); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/AnswerOpLog.java b/src/main/java/com/yunhai/formcenter/entity/AnswerOpLog.java new file mode 100644 index 0000000..d4fb20a --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/AnswerOpLog.java @@ -0,0 +1,59 @@ +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; + +/** + * 操作留痕(契约 §10 / 方案 §5.4):管理员编辑、软删、批量操作逐条一行。 + * + *批量与单条同形(方案 §5.4"批量逐条走单条校验与留痕"):一条答卷一行日志、同一操作人、 + * 同一时间口径;op 用 {@code batch_*} 前缀标出来自批量通道(契约 §10 注释的 + * {@code edit/soft_delete/batch_…} 形态),批次上下文写在 summary 里。
+ * + *填表人没有编辑/撤回接口可用(V29)——所以本表里不会出现"填表人自己 edit"的行。
+ */ +@Getter +@Setter +@Entity +@Table(name = "answer_op_log", indexes = @Index(name = "idx_ol_answer", columnList = "answer_id")) +public class AnswerOpLog extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 管理员原地修正值文档(按钉住版本校验,方案 §5.4) */ + public static final String OP_EDIT = "edit"; + /** 软删(唯一删除形态) */ + public static final String OP_SOFT_DELETE = "soft_delete"; + /** 改状态(非填表人;细节同时进 status_log) */ + public static final String OP_CHANGE_STATUS = "change_status"; + /** 批量软删中的某一条 */ + public static final String OP_BATCH_SOFT_DELETE = "batch_soft_delete"; + /** 批量改状态中的某一条 */ + public static final String OP_BATCH_CHANGE_STATUS = "batch_change_status"; + + @Column(name = "answer_id", nullable = false, updatable = false) + private Long answerId; + + @Column(name = "op", nullable = false, length = 32, updatable = false) + private String op; + + @Column(name = "operator_id", nullable = false, updatable = false) + private Integer operatorId; + + /** 摘要(改了哪些字段/批次上下文等),≤500 */ + @Column(name = "summary", length = 500) + private String summary; + + public static String summarize(String summary) { + if (summary == null || summary.trim().isEmpty()) { + return null; + } + String clean = summary.trim(); + return clean.length() > 500 ? clean.substring(0, 500) : clean; + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/FillQuota.java b/src/main/java/com/yunhai/formcenter/entity/FillQuota.java new file mode 100644 index 0000000..f0a37aa --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/FillQuota.java @@ -0,0 +1,36 @@ +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; + +/** + * 限填登记(契约 §10 / 方案 §5.4 v1.4 并发兜底):{@code (form_id, limit_key)} 唯一键, + * 提交事务内插入、撞键即拒——两个并发提交不可能都通过 count 检查。 + * + *为什么需要这张表:契约 §3 把 image/upload 的值形状定死为 + * {@code [{id, fileName, key}]}——那个 {@code id} 必须是中心侧可定位的附件行主键, + * 否则"值里存 id"无从解析(管理员编辑重存、附件审计、后续 GC 都按它)。 + * 契约 §10 九表草案未列本表 → 见问题单(本轮按值契约补齐最小落档结构,不扩语义)。
+ * + *环境解耦纪律(方案 §4.3② v1.1 修订):只存 {@code store_key}=相对存储路径, + * 绝不存绝对 URL——{@code FILE_WEB_PATH} 是环境注入项,绝对地址进答卷=换域名历史附件全断。 + * 访问地址由渲染期拼接;一期显式假设"key=不可猜测随机串即访问控制"(方案 §7.7,鉴权接口二期)。
+ */ +@Getter +@Setter +@Entity +@Table(name = "form_file", + uniqueConstraints = @UniqueConstraint(name = "uk_form_file_key", columnNames = "store_key"), + indexes = @Index(name = "idx_form_file_sch", columnList = "sch_id, id")) +public class FormFile extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 上传时的会话校(归属留档;跨校不可读由读路径负责) */ + @Column(name = "sch_id", nullable = false, updatable = false) + private Long schId; + + /** 用户上传时的原始文件名(展示用;存储名另生成,防路径穿越与同名覆盖) */ + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + /** + * 相对存储 key({@code FILE_PATH} 下),形如 {@code form/2026/10/{32位随机}.{ext}}。 + * 列宽取 191:本列上有唯一键,utf8mb4 下 191×4=764 字节刚好落在 InnoDB 单列索引 767 上限内 + * (方案 §7.7 要求 MySQL 5.7 兼容;实际 key 只有 ~50 字符,191 已留足余量)。 + */ + @Column(name = "store_key", nullable = false, length = 191, updatable = false) + private String storeKey; + + /** 字节数(上限校验在上传时执行;留档供后续配额/GC 判断) */ + @Column(name = "file_size", nullable = false) + private Long fileSize; + + /** 归一化小写扩展名(不含点),黑名单判定结果留档 */ + @Column(name = "ext", nullable = false, length = 16) + private String ext; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/StatusLog.java b/src/main/java/com/yunhai/formcenter/entity/StatusLog.java new file mode 100644 index 0000000..bf50b02 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/StatusLog.java @@ -0,0 +1,35 @@ +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; + +/** + * 状态留痕(契约 §10 / 方案 §5.3④):改状态写一行,一期不做通知。 + * + *红线复述:状态是"标记"不是"流转"——本表只记"谁在什么时候把这条答卷标成了什么", + * 系统里不存在任何转移规则校验(V15)。需要流转走流程中心。
+ */ +@Getter +@Setter +@Entity +@Table(name = "status_log", indexes = @Index(name = "idx_sl_answer", columnList = "answer_id")) +public class StatusLog extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @Column(name = "answer_id", nullable = false, updatable = false) + private Long answerId; + + /** 状态 code(取值域=表单 status_defs,含已废弃 code 的历史痕迹) */ + @Column(name = "status_code", nullable = false, length = 64, updatable = false) + private String statusCode; + + /** 操作人(CAS 会话用户 id;填表人改状态在接口层即被拒,方案 §5.3③/V14) */ + @Column(name = "operator_id", nullable = false, updatable = false) + private Integer operatorId; +} diff --git a/src/main/java/com/yunhai/formcenter/model/AnswerModels.java b/src/main/java/com/yunhai/formcenter/model/AnswerModels.java new file mode 100644 index 0000000..0ac79a5 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/AnswerModels.java @@ -0,0 +1,113 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 答卷域出入参模型(契约 §3/§5/§7 的响应侧形状)。 + * + *三条对外纪律:
+ *