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 ATTACHMENT_TYPES = new HashSet<>(Arrays.asList("upload", "image")); + private static final Set TEXT_TYPES = new HashSet<>(Arrays.asList("text", "textarea", "phone")); + private static final Set SINGLE_OPTION_TYPES = new HashSet<>(Arrays.asList("select", "radio")); + private static final Set MULTI_OPTION_TYPES = new HashSet<>(Arrays.asList("checkbox", "multiselect")); + private static final Set ATTACHMENT_PROPS = new HashSet<>(Arrays.asList("id", "fileName", "key")); + + private FormValueValidator() { + } + + /** 字典候选解析(由服务层注入,保持本类可脱库单测) */ + public interface DictCandidates { + Set valuesOf(String dictCode); + } + + /** + * 校验提交值。 + * + * @param valuesJson 提交的 {@code {key:value}} 文档 + * @param schemaJson 钉住版本的 schema 原文(未经字典展开) + * @return 原样返回 {@code valuesJson}(校验不改写用户数据;存储即所提交) + * @throws BizException PARAM_ERROR——msg 指明字段与原因(契约 §6 定稿映射) + */ + public static String validate(String valuesJson, String schemaJson, DictCandidates dictCandidates) { + Map fields = FormSchemaValidator.fieldsByKeys(schemaJson); + JsonNode data = FormSchemaValidator.parse( + valuesJson == null || valuesJson.trim().isEmpty() ? "{}" : valuesJson, + "表单数据不是合法 JSON 对象"); + if (!data.isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单数据必须是 JSON 对象"); + } + rejectUnknownKeys(data, fields); + for (Map.Entry entry : fields.entrySet()) { + JsonNode field = entry.getValue(); + String key = entry.getKey(); + JsonNode value = data.get(key); + if (isEmptyValue(value)) { + if (isRequired(field)) { + throw fail(field, key, "为必填项,未填写"); + } + continue; + } + validateValue(field, key, value, dictCandidates); + } + return valuesJson; + } + + /** 提交值里引用的附件 id(服务层据此做同校归属校验,防把别人的 key 塞进自己的答卷) */ + public static List attachmentFileIds(String valuesJson, String schemaJson) { + List ids = new ArrayList<>(); + Map fields = FormSchemaValidator.fieldsByKeys(schemaJson); + JsonNode data = FormSchemaValidator.parse( + valuesJson == null || valuesJson.trim().isEmpty() ? "{}" : valuesJson, "表单数据不是合法 JSON 对象"); + if (!data.isObject()) { + return ids; + } + for (Map.Entry entry : fields.entrySet()) { + if (!ATTACHMENT_TYPES.contains(entry.getValue().path("type").asText())) { + continue; + } + JsonNode value = data.get(entry.getKey()); + if (value == null || !value.isArray()) { + continue; + } + for (JsonNode item : value) { + JsonNode id = item.get("id"); + if (id != null && id.isIntegralNumber()) { + ids.add(id.longValue()); + } + } + } + return ids; + } + + // ==================== 分派 ==================== + + private static void validateValue(JsonNode field, String key, JsonNode value, DictCandidates dicts) { + String type = field.path("type").asText(); + if (TEXT_TYPES.contains(type)) { + validateText(field, key, value); + } else if ("number".equals(type)) { + validateNumber(field, key, value); + } else if ("date".equals(type)) { + validateDate(field, key, value); + } else if (SINGLE_OPTION_TYPES.contains(type)) { + validateSingleOption(field, key, value, dicts); + } else if (MULTI_OPTION_TYPES.contains(type)) { + validateMultiOption(field, key, value, dicts); + } else if (ATTACHMENT_TYPES.contains(type)) { + validateAttachments(field, key, value); + } else { + // 二期类型(address/user/users)不可能到这里:发布期已被 PHASE1_TYPES 拦下 + throw fail(field, key, "该类型一期未放行:" + type); + } + } + + private static void validateText(JsonNode field, String key, JsonNode value) { + if (!value.isTextual()) { + throw fail(field, key, "必须是字符串"); + } + String text = value.asText(); + Integer maxLength = intVal(field, "maxLength"); + if (maxLength != null && text.length() > maxLength) { + throw fail(field, key, "长度超过上限 " + maxLength + "(当前 " + text.length() + ")"); + } + String pattern = text(field, "pattern"); + if (pattern != null && !Pattern.compile(pattern).matcher(text).matches()) { + throw fail(field, key, "格式不正确"); + } + } + + /** number 值形状={@code number|null}(契约 §3:"空为 null,不是 \"\"") */ + private static void validateNumber(JsonNode field, String key, JsonNode value) { + if (!value.isNumber()) { + throw fail(field, key, "必须是数字(留空请传 null,不要传空字符串)"); + } + BigDecimal decimal = value.decimalValue(); + Integer precision = intVal(field, "precision"); + if (precision != null && decimal.scale() > precision) { + throw fail(field, key, "小数位数超过 precision=" + precision + "(当前 " + decimal.scale() + ")"); + } + Double min = doubleVal(field, "min"); + Double max = doubleVal(field, "max"); + if (min != null && decimal.doubleValue() < min) { + throw fail(field, key, "不得小于 " + min); + } + if (max != null && decimal.doubleValue() > max) { + throw fail(field, key, "不得大于 " + max); + } + } + + /** date 存储格式在契约层定死 {@code "YYYY-MM-DD"},且必须是真实存在的日期(V4 的根因) */ + private static void validateDate(JsonNode field, String key, JsonNode value) { + if (!value.isTextual()) { + throw fail(field, key, "必须是 \"YYYY-MM-DD\" 字符串"); + } + String text = value.asText(); + if (!DATE_STRICT.matcher(text).matches()) { + throw fail(field, key, "日期格式必须为 YYYY-MM-DD(当前 " + text + ")"); + } + try { + LocalDate.parse(text, DATE_PARSER); + } catch (DateTimeParseException e) { + throw fail(field, key, "日期不存在:" + text); + } + } + + private static void validateSingleOption(JsonNode field, String key, JsonNode value, DictCandidates dicts) { + if (!value.isTextual()) { + throw fail(field, key, "必须是选项 value 字符串(存 value 不存 label)"); + } + Set candidates = candidates(field, dicts); + if (!candidates.contains(value.asText())) { + throw fail(field, key, "选项值不在候选列表内:" + value.asText()); + } + } + + private static void validateMultiOption(JsonNode field, String key, JsonNode value, DictCandidates dicts) { + if (!value.isArray()) { + throw fail(field, key, "必须是选项 value 数组(存 value 数组,展示顺序按 options 定义序)"); + } + Set candidates = candidates(field, dicts); + for (JsonNode item : value) { + if (!item.isTextual()) { + throw fail(field, key, "数组元素必须是选项 value 字符串"); + } + if (!candidates.contains(item.asText())) { + throw fail(field, key, "选项值不在候选列表内:" + item.asText()); + } + } + } + + /** + * 附件值形状 {@code [{id, fileName, key}]}(方案 §4.3② v1.1 修订): + * 只存 id/文件名/相对 key,不存 fileUrl——绝对 URL 进答卷=环境耦合,换域名历史附件全断。 + */ + private static void validateAttachments(JsonNode field, String key, JsonNode value) { + if (!value.isArray()) { + throw fail(field, key, "必须是附件数组 [{id,fileName,key}]"); + } + Integer maxFiles = intVal(field, "maxFiles"); + int limit = maxFiles != null && maxFiles > 0 ? Math.min(maxFiles, MAX_FILES_PER_FIELD) : MAX_FILES_PER_FIELD; + if (value.size() > limit) { + throw fail(field, key, "附件数超过上限 " + limit); + } + for (JsonNode item : value) { + if (!item.isObject()) { + throw fail(field, key, "附件项必须是 {id,fileName,key} 对象"); + } + Iterator names = item.fieldNames(); + while (names.hasNext()) { + String prop = names.next(); + if (!ATTACHMENT_PROPS.contains(prop)) { + throw fail(field, key, "附件项含契约外字段(只允许 id/fileName/key):" + prop); + } + } + JsonNode id = item.get("id"); + if (id == null || !id.isIntegralNumber() || id.longValue() <= 0) { + throw fail(field, key, "附件项 id 必须是正整数(中心上传接口返回值)"); + } + String fileName = text(item, "fileName"); + if (fileName == null || fileName.trim().isEmpty()) { + throw fail(field, key, "附件项缺少 fileName"); + } + String storeKey = text(item, "key"); + if (storeKey == null || storeKey.trim().isEmpty()) { + throw fail(field, key, "附件项缺少 key"); + } + assertRelativeKey(field, key, storeKey); + } + } + + /** key 必须是相对存储路径:拒绝绝对 URL / 协议头 / 绝对路径 / 上跳(V24"答卷值中不含绝对 URL") */ + private static void assertRelativeKey(JsonNode field, String key, String storeKey) { + String lower = storeKey.toLowerCase(); + if (lower.contains("://") || lower.startsWith("//") || lower.startsWith("/") || storeKey.contains("..") + || lower.startsWith("file:")) { + throw fail(field, key, "附件 key 必须是相对存储路径,不接受绝对 URL 或跨目录路径:" + storeKey); + } + } + + // ==================== 取值域与工具 ==================== + + /** 候选值集合:内联 options 的 value,或字典引用解析结果(含软删条目,P18/P19) */ + private static Set candidates(JsonNode field, DictCandidates dicts) { + String dictCode = FormSchemaValidator.dictRefCode(field); + if (dictCode != null) { + Set resolved = dicts == null ? null : dicts.valuesOf(dictCode); + if (resolved == null || resolved.isEmpty()) { + // 放行集为空=字典已删或从未建:宁可拒提交,也不放行任意值(契约 §2.1 的伪造 value 拦截) + throw fail(field, field.path("key").asText(), "引用的字典不可用:" + dictCode); + } + return resolved; + } + Set values = new LinkedHashSet<>(); + JsonNode options = field.get("options"); + if (options != null && options.isArray()) { + for (JsonNode option : options) { + String value = text(option, "value"); + if (value != null) { + values.add(value); + } + } + } + return values; + } + + private static void rejectUnknownKeys(JsonNode data, Map fields) { + List unknown = new ArrayList<>(); + Iterator names = data.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + if (!fields.containsKey(name)) { + unknown.add(name); + } + } + if (!unknown.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单数据含未知字段:" + String.join(",", unknown)); + } + } + + /** 空值口径(与 svc-hall 同源):null / 空串(含纯空白)/ 空数组 */ + private static boolean isEmptyValue(JsonNode value) { + if (value == null || value.isNull()) { + return true; + } + if (value.isTextual()) { + return value.asText().trim().isEmpty(); + } + return value.isArray() && value.size() == 0; + } + + private static boolean isRequired(JsonNode field) { + JsonNode required = field.get("required"); + return required != null && required.isBoolean() && required.booleanValue(); + } + + private static BizException fail(JsonNode field, String key, String reason) { + String label = text(field, "label"); + return new BizException(ErrorCode.PARAM_ERROR, + "字段「" + (label == null || label.trim().isEmpty() ? key : label) + "」" + reason + + "(" + key + ")"); + } + + private static String text(JsonNode node, String prop) { + JsonNode value = node.get(prop); + return value == null || value.isNull() ? null : value.asText(); + } + + private static Integer intVal(JsonNode node, String prop) { + JsonNode value = node.get(prop); + return value != null && value.isIntegralNumber() ? value.intValue() : null; + } + + private static Double doubleVal(JsonNode node, String prop) { + JsonNode value = node.get(prop); + return value != null && value.isNumber() ? value.asDouble() : null; + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java new file mode 100644 index 0000000..6e69f21 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java @@ -0,0 +1,80 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.service.AnswerAdminService; +import com.yunhai.formcenter.service.FormAdminGuard; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +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; + +/** + * 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。 + * + *

这里没有编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(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 batchSoftDelete(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody AnswerModels.BatchRequest body) { + Long domain = adminGuard.requireAdmin(user, schId); + return adminService.batchSoftDelete(domain, user.getId(), body); + } + + /** 批量改状态:同上 */ + @PostMapping("/batch/changeStatus") + public List batchChangeStatus(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody AnswerModels.BatchRequest body) { + Long domain = adminGuard.requireAdmin(user, schId); + return adminService.batchChangeStatus(domain, user.getId(), body); + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerController.java new file mode 100644 index 0000000..30dc139 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerController.java @@ -0,0 +1,50 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.service.AnswerService; +import com.yunhai.formcenter.service.FormFileService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +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 org.springframework.web.multipart.MultipartFile; + +/** + * 填报面(用户面,CAS 会话):提交与附件上传。 + * + *

契约纪律:

+ *
    + *
  • 不存在 oapi 代提交(方案 §2.3 边界):提交只能由浏览器直连 CAS 会话发起, + * 身份({@code sch_id}/{@code submitter_id})一律会话推导,请求体里没有这两个字段(V17);
  • + *
  • 提交返回 200 + {@code {answerId, answerNo}}:幂等键命中同样是 200(不是 409,契约 §5), + * {@code answerId} 是答卷 uuid——前端"提交后跳结果页/{@code {answerId}} 占位回传"用的就是它(契约 §7);
  • + *
  • 上传只返回 {@code {id, fileName, key}}:无 fileUrl(V24,绝对 URL 由渲染期拼 + * {@code FILE_WEB_PATH})。
  • + *
+ */ +@RestController +@RequestMapping("/api/form") +@RequiredArgsConstructor +public class FormAnswerController { + + private final AnswerService answerService; + private final FormFileService fileService; + + /** 提交答卷(契约 §5 端点) */ + @PostMapping("/submit") + public AnswerModels.SubmitView submit(BoaUser user, @RequestBody AnswerModels.SubmitRequest body) { + return answerService.submit(user, body); + } + + /** + * 附件上传(单文件)。每字段 ≤9 个的上限在提交值校验里判(契约 §3 值是数组,一次提交才看得全); + * 单文件 ≤50MB 与扩展名黑名单在这里判(容器侧上限见 application.yml 的 spring.servlet.multipart,P12)。 + */ + @PostMapping("/upload") + public AnswerModels.FileView upload(BoaUser user, @RequestParam("file") MultipartFile file) { + return fileService.upload(user, file); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java new file mode 100644 index 0000000..3160d04 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -0,0 +1,90 @@ +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; + +/** + * 答卷(契约 §5/§10,方案 §5.1/§5.2):一答卷一行(旧系统 EAV 每字段一行的反面)。 + * + *

三条钉死的事实:

+ *
    + *
  • 版本钉住:{@code (sch_id, app_code, code, version)} 是提交时解析到的版本冗余下来 + * (方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20), + * 在途提交天然按旧版落地,编辑/详情/导出也都按这一行列语义走。
  • + *
  • 身份三键(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、 + * {@code sch_id}=会话校,全部会话推导不可外部传入(契约 §5、V17)。
  • + *
  • 幂等键是库级唯一:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1), + * 与"限一次"({@code fill_quota})是两个机制——幂等防重复提交,限次防多次提交。
  • + *
+ * + *

{@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} 数值)。 + * + *

四条纪律:

+ *
    + *
  1. 只写显式 {@code searchable} 的字段(V3/V9)——未声明的字段不进索引,也就不进筛选候选;
  2. + *
  3. 多值字段(checkbox/multiselect)一值一行,等值筛选才命中;
  4. + *
  5. {@code v_str} 定 varchar(191):utf8mb4 下 767 字节索引上限,超长值截断入索引 + * (筛选语义=前缀匹配,契约 §10 已认可);
  6. + *
  7. 生命周期:新版本删字段不清历史索引行(否则 V8"历史仍可筛"失效); + * 只有答卷软删时同步删行({@code idx_ai_answer} 就是为这条删路径建的)。
  8. + *
+ * + *

列选择按该答卷钉住版本的 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 检查。 + * + *
    + *
  • {@code limit_key} = {@code coalesce(subject_id, submitter_id)} 字符串化({@code FormRuntimeRules#quotaKey});
  • + *
  • 软删答卷同步删登记行 = 释放名额(管理员删错单后学生可重填,方案 §5.4 点名"第一天就会发生的场景");
  • + *
  • 不限次表单不写本表(无名额可占)。
  • + *
+ */ +@Getter +@Setter +@Entity +@Table(name = "fill_quota", + uniqueConstraints = @UniqueConstraint(name = "uk_quota", columnNames = {"form_id", "limit_key"})) +public class FillQuota extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 表单头表 id(限次是表单级配置,跨版本共享名额——升版不清零,语义与"报名"一致) */ + @Column(name = "form_id", nullable = false, updatable = false) + private Long formId; + + @Column(name = "limit_key", nullable = false, length = 128, updatable = false) + private String limitKey; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/FormFile.java b/src/main/java/com/yunhai/formcenter/entity/FormFile.java new file mode 100644 index 0000000..a7cd6b6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/FormFile.java @@ -0,0 +1,57 @@ +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; + +/** + * 中心上传的附件记录(平台标准版上传模块的落档行)。 + * + *

为什么需要这张表:契约 §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 的响应侧形状)。 + * + *

三条对外纪律:

+ *
    + *
  • 身份字段不进请求:{@code schId/submitterId} 一律会话推导,DTO 里没有这两个字段, + * 客户端即使传了也被 Spring 忽略(Boot 默认 {@code FAIL_ON_UNKNOWN_PROPERTIES=false})—— + * 这是 V17"接口不接受外部传入"的实现形态;
  • + *
  • 数据面身份只用 uuid:请求与响应都出现 {@code answerId}=答卷 uuid, + * 库内 {@code id} 不出现在任何外层(契约 §1/V5);
  • + *
  • 附件返回值只含 {@code {id, fileName, key}}:没有 fileUrl 字段—— + * 绝对 URL 由渲染期拼 {@code FILE_WEB_PATH}(方案 §4.3②,V24)。
  • + *
+ */ +public final class AnswerModels { + + private AnswerModels() { + } + + /** 提交请求(契约 §5 请求体的字段闭集) */ + @Getter + @Setter + public static class SubmitRequest { + /** 表单 code(入口寻址用的那一个,契约 §1) */ + private String formCode; + /** 归属应用,缺省 form-center;快应用填报页传快应用 app_code */ + private String appCode; + /** 客户端生成的幂等键(uuid 形态;uk_answer_idem) */ + private String idempotencyKey; + /** 可选:关于谁。普通用户只能=自己;有管理权者可指定他人(方案 §6.3/V17) */ + private Integer subjectId; + /** 值文档 {@code {key:value}},形状见契约 §3 */ + private JsonNode values; + } + + /** 提交响应(契约 §5:{@code {answerId, answerNo}};answerNo 在未开启报名号时为 null) */ + @Getter + @Setter + public static class SubmitView { + private String answerId; + private String answerNo; + /** 幂等键命中原答卷时为 true(仍返回 200,契约 §5"不是 409") */ + private Boolean replayed; + } + + /** 上传返回值:字段闭集就是契约 §3 的 {@code {id, fileName, key}} 三件 */ + @Getter + @Setter + public static class FileView { + private Long id; + private String fileName; + private String key; + } + + /** 管理员原地修正值文档(按答卷钉住的 version 校验;不改状态、不改身份) */ + @Getter + @Setter + public static class EditRequest { + private String answerId; + private JsonNode values; + /** 改动说明,进 answer_op_log.summary */ + private String summary; + } + + /** 改状态(非填表人;写 status_log,无任何转移规则校验——V15) */ + @Getter + @Setter + public static class ChangeStatusRequest { + private String answerId; + private String status; + } + + /** 批量:软删或改状态。逐条走单条校验与留痕(方案 §5.4) */ + @Getter + @Setter + public static class BatchRequest { + private List answerIds; + /** 批量改状态时必填;批量软删时忽略 */ + private String status; + } + + /** 批量逐条结果(一条一行,失败不影响其它条——每条独立事务) */ + @Getter + @Setter + public static class BatchItemView { + private String answerId; + private Boolean success; + private String msg; + + public static BatchItemView ok(String answerId) { + BatchItemView item = new BatchItemView(); + item.setAnswerId(answerId); + item.setSuccess(true); + return item; + } + + public static BatchItemView fail(String answerId, String msg) { + BatchItemView item = new BatchItemView(); + item.setAnswerId(answerId); + item.setSuccess(false); + item.setMsg(msg); + return item; + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java new file mode 100644 index 0000000..ba10a80 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java @@ -0,0 +1,24 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.AnswerIndex; +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; + +/** + * 索引值表仓库。删除只有两个场景(方案 §5.2 索引行生命周期): + * ①管理员编辑后重建;②答卷软删时同步删。 + * 不存在"按新版本 schema 清理旧字段行"的方法——那会打掉 V8"删字段后历史仍可筛"。 + */ +public interface AnswerIndexRepository extends JpaRepository { + + List findByAnswerIdOrderByIdAsc(Long answerId); + + /** 重建/软删清理:bulk delete(方案 §7.7 全量覆盖式保存用 @Modifying) */ + @Modifying + @Query("delete from AnswerIndex x where x.answerId = :answerId") + int deleteByAnswerId(@Param("answerId") Long answerId); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerOpLogRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerOpLogRepository.java new file mode 100644 index 0000000..4480d78 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerOpLogRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.AnswerOpLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 操作留痕仓库:只增不改不删。批量与单条同形——一条答卷一行日志(方案 §5.4), + * 批量通道只是 op 值不同({@code batch_*})并在 summary 里带批次上下文。 + */ +public interface AnswerOpLogRepository extends JpaRepository { + + List findByAnswerIdOrderByIdAsc(Long answerId); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java new file mode 100644 index 0000000..3c1b428 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java @@ -0,0 +1,34 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.Answer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * 答卷仓库。数据面一律按 {@code uuid + sch_id} 定位(契约 §1:id 绝不外露、uuid 不可枚举)。 + * 检索/统计/导出的读路径在 T4(四读出路径共用同一 scope 构造器,方案 §6.4 红线), + * 本轮只放提交与管理操作必需的查询——不提供任何物理删除方法(P22/V30)。 + */ +public interface AnswerRepository extends JpaRepository { + + /** 幂等键定位(uk_answer_idem,库级唯一) */ + Optional findByIdempotencyKey(String idempotencyKey); + + Optional findByUuidAndSchId(String uuid, Long schId); + + /** + * 报名号当日序号:统计该 (校, app_code, code) 下以 {@code {code}-{YYYYMMDD}-} 开头的行数(含软删行—— + * 软删不回收号,否则同日重填会出现两个相同报名号)。前缀由服务端拼接,参数绑定进 JPQL。 + */ + @Query("select count(a) from Answer a where a.schId = :schId and a.appCode = :appCode and a.code = :code " + + "and a.answerNo like :prefix") + long countTodayAnswerNo(@Param("schId") Long schId, @Param("appCode") String appCode, + @Param("code") String code, @Param("prefix") String prefix); + + /** 测试与审计取证:某表单下的全部答卷(按 id 升序) */ + List findBySchIdAndAppCodeAndCodeOrderByIdAsc(Long schId, String appCode, String code); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FillQuotaRepository.java b/src/main/java/com/yunhai/formcenter/repository/FillQuotaRepository.java new file mode 100644 index 0000000..82bcafe --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/FillQuotaRepository.java @@ -0,0 +1,27 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.FillQuota; +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; + +/** + * 限填登记仓库。{@code uk_quota(form_id, limit_key)} 就是并发兜底本身(方案 §5.4 v1.4): + * 提交事务内插入、撞键即拒——所以这里不做"先 count 再插"的伪检查,count 检查在并发下靠不住。 + */ +public interface FillQuotaRepository extends JpaRepository { + + /** 名额是否已被占(用于给出"您已提交过"的可读提示;真正的拒绝仍由插入撞键完成) */ + boolean existsByFormIdAndLimitKey(Long formId, String limitKey); + + /** 某表单的名额占用清单(管理端"已填人数"与运维排查用) */ + List findByFormId(Long formId); + + /** 软删答卷=释放名额(P22/方案 §5.4:管理员删错单后学生可重填) */ + @Modifying + @Query("delete from FillQuota q where q.formId = :formId and q.limitKey = :limitKey") + int releaseQuota(@Param("formId") Long formId, @Param("limitKey") String limitKey); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormFileRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormFileRepository.java new file mode 100644 index 0000000..e194af4 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/FormFileRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.FormFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * 附件落档仓库。一期只提供两件事:建行(上传成功即落档)与同校归属校验 + * (提交值里引用的附件 id 必须是本校上传过的行,防止把别人的 key 塞进自己的答卷)。 + * 没有删除接口——附件物理清理由二期(方案 U11 族)处理。 + */ +public interface FormFileRepository extends JpaRepository { + + Optional findByIdAndSchId(Long id, Long schId); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java index 68f40c9..e8f5de8 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java @@ -29,10 +29,20 @@ public interface FormRepository extends JpaRepository { @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 校域不变式) */ + /** 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); + + /** + * 数据面取域(T3 答卷溯源):同一条遮蔽规则,但不排除软删表单—— + * 表单只有软删、没有物理删除(方案 §5.4),其历史答卷仍要可按钉住版本校验与溯源, + * 否则"删掉表单"会连带把存量数据变成无法管理的孤儿。 + */ + @Query("select f from Form f where f.code = :code and f.appCode = :appCode and f.schId in (:schIds)") + List findDataDomainCandidates(@Param("code") String code, + @Param("appCode") String appCode, + @Param("schIds") Collection schIds); } diff --git a/src/main/java/com/yunhai/formcenter/repository/StatusLogRepository.java b/src/main/java/com/yunhai/formcenter/repository/StatusLogRepository.java new file mode 100644 index 0000000..903e321 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/StatusLogRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.StatusLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 状态留痕仓库:只增、不改、不删(痕迹是审计资产,与 {@code answer_index} 的"软删同步删行"不同)。 + * 改状态一律写这里,系统内不存在任何转移规则校验(V15)。 + */ +public interface StatusLogRepository extends JpaRepository { + + List findByAnswerIdOrderByIdAsc(Long answerId); +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java new file mode 100644 index 0000000..67b1ff6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java @@ -0,0 +1,231 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.common.FormRuntimeRules; +import com.yunhai.formcenter.common.FormStatusDefs; +import com.yunhai.formcenter.common.FormValueValidator; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerOpLog; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.StatusLog; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.repository.AnswerOpLogRepository; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FillQuotaRepository; +import com.yunhai.formcenter.repository.FormFileRepository; +import com.yunhai.formcenter.repository.StatusLogRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; + +/** + * 答卷管理操作(契约 §5/§10,方案 §5.4 生命周期表"谁碰、怎么碰"那一列的落地)。 + * + *
    + *
  • 编辑:仅管理员,原地修正值文档;按该答卷提交时钉住的 version 校验与渲染 + * (V8 同族——新 schema 的字段不会凭空出现在旧数据上,旧 schema 已删的字段也不会被新校验误拒); + * 写 {@code answer_op_log},索引同步重建。不改状态、不改身份、不看截止时间。
  • + *
  • 软删:唯一删除形态(无物理删除接口,V30/P22);同步删 {@code answer_index} 行 + * (方案 §5.2)+ 释放 fill_quota 名额(管理员删错单后学生可重填,方案 §5.4 点名场景); + * 留痕表不动——痕迹是审计资产。
  • + *
  • 改状态:只写 {@code status_log},系统内不存在任何转移规则校验(V15); + * 填表人在接口层即被拒(V14)。目标状态必须是该表单状态定义里的未废弃 code。
  • + *
  • 批量:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务, + * 一条失败不回滚其它条,结果逐条返回。这是"批量与单条完全一致"的实现形态。
  • + *
+ * + *

权限:调用方(控制器)已用 {@code FormAdminGuard} 拿到管理权与租户域,本服务只按 + * {@code (schId, answerUuid)} 定位——跨校答卷根本查不到,越权面在定位这一步就关死。 + * 数据范围(契约 §8 矩阵/scope 构造器)属 T4,本轮不做行级过滤。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AnswerAdminService { + + /** 批量单次上限(安全阈值,非契约条款:批量是一条条独立事务做的,不给一次请求挂几千条的口子) */ + static final int MAX_BATCH_SIZE = 200; + + private final AnswerRepository answerRepository; + private final AnswerOpLogRepository opLogRepository; + private final StatusLogRepository statusLogRepository; + private final FillQuotaRepository quotaRepository; + private final FormFileRepository fileRepository; + private final FormDefService defService; + private final FormDictService dictService; + private final AnswerIndexService indexService; + private final TransactionTemplate transactionTemplate; + + // ==================== 单条 ==================== + + /** 管理员编辑:按钉住版本校验值文档,原地修正 + 重建索引 + 留痕(V29) */ + public void edit(Long schId, Integer operatorId, AnswerModels.EditRequest req) { + transactionTemplate.executeWithoutResult(tx -> doEdit(schId, operatorId, req)); + } + + /** 软删:唯一删除形态;同步删索引行、释放限次名额、留痕(V30/V32 后半) */ + public void softDelete(Long schId, Integer operatorId, String answerUuid) { + transactionTemplate.executeWithoutResult(tx -> doSoftDelete(schId, operatorId, answerUuid, false)); + } + + /** 改状态:非填表人(接口层由守卫把关,V14);写 status_log,无转移校验(V15) */ + public void changeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req) { + transactionTemplate.executeWithoutResult(tx -> doChangeStatus(schId, operatorId, req, false)); + } + + // ==================== 批量(逐条独立事务) ==================== + + public List batchSoftDelete(Long schId, Integer operatorId, + AnswerModels.BatchRequest req) { + List ids = requireBatchIds(req.getAnswerIds()); + List results = new ArrayList<>(); + for (String answerUuid : ids) { + results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult( + tx -> doSoftDelete(schId, operatorId, answerUuid, true)))); + } + return results; + } + + public List batchChangeStatus(Long schId, Integer operatorId, + AnswerModels.BatchRequest req) { + List ids = requireBatchIds(req.getAnswerIds()); + List results = new ArrayList<>(); + for (String answerUuid : ids) { + AnswerModels.ChangeStatusRequest one = new AnswerModels.ChangeStatusRequest(); + one.setAnswerId(answerUuid); + one.setStatus(req.getStatus()); + results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult( + tx -> doChangeStatus(schId, operatorId, one, true)))); + } + return results; + } + + // ==================== 实现(每条一个独立事务的执行体) ==================== + + private void doEdit(Long schId, Integer operatorId, AnswerModels.EditRequest req) { + Answer answer = requireLiveAnswer(schId, req.getAnswerId()); + FormDefService.Published pinned = defService.pinnedForData( + answer.getSchId(), answer.getAppCode(), answer.getCode(), answer.getVersion()); + String schemaJson = pinned.versionRow().getSchemaJson(); + + if (req.getValues() != null && !req.getValues().isNull() && !req.getValues().isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "values 必须是 JSON 对象 {key:value}"); + } + String valuesJson = req.getValues() == null || req.getValues().isNull() ? "{}" : req.getValues().toString(); + FormValueValidator.validate(valuesJson, schemaJson, + dictCode -> dictService.allowedValues(answer.getSchId(), dictCode)); + assertAttachmentsOwned(answer.getSchId(), valuesJson, schemaJson); + + answer.setValuesJson(valuesJson); + answerRepository.save(answer); + // 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变 + indexService.rebuild(answer, schemaJson); + opLog(answer.getId(), AnswerOpLog.OP_EDIT, operatorId, + req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")" + : "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary()); + log.info("[编辑] answer={} 按 v{} 校验通过并重建索引", answer.getUuid(), answer.getVersion()); + } + + private void doSoftDelete(Long schId, Integer operatorId, String answerUuid, boolean batched) { + Answer answer = requireLiveAnswer(schId, answerUuid); + answer.setIsDeleted(1); + answerRepository.save(answer); + indexService.clear(answer.getId()); + // 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。 + // 不按"当前是否仍限次"判断——配置改回不限次后残留名额仍会挡住重填。 + Long formId = defService.pinnedForData(answer.getSchId(), answer.getAppCode(), answer.getCode(), + answer.getVersion()).form().getId(); + quotaRepository.releaseQuota(formId, + FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId())); + opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_SOFT_DELETE : AnswerOpLog.OP_SOFT_DELETE, + operatorId, batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)"); + } + + private void doChangeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req, + boolean batched) { + Answer answer = requireLiveAnswer(schId, req.getAnswerId()); + String status = req.getStatus() == null ? null : req.getStatus().trim(); + if (status == null || status.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "status 不能为空"); + } + FormDefService.Published pinned = defService.pinnedForData( + answer.getSchId(), answer.getAppCode(), answer.getCode(), answer.getVersion()); + Form form = pinned.form(); + // 目标状态必须是该表单定义里的未废弃 code;已废弃 code 只用于历史回显(V13 由读路径降级显示) + boolean live = FormStatusDefs.liveDefs(form.getStatusDefs()).stream() + .anyMatch(def -> status.equals(def.getCode())); + if (!live) { + throw new BizException(ErrorCode.PARAM_ERROR, + "状态不在该表单的可用定义内(已废弃或未定义):" + status); + } + String oldStatus = answer.getStatus(); + answer.setStatus(status); + answerRepository.save(answer); + StatusLog entry = new StatusLog(); + entry.setAnswerId(answer.getId()); + entry.setStatusCode(status); + entry.setOperatorId(operatorId); + statusLogRepository.save(entry); + // 留痕与单条完全一致(方案 §5.4):一条答卷一行;批量通道只在 op 与 summary 上标出来自批量 + opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS, + operatorId, (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status + + "(无转移规则校验)"); + } + + // ==================== 辅助 ==================== + + private Answer requireLiveAnswer(Long schId, String answerUuid) { + if (answerUuid == null || answerUuid.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空"); + } + Answer answer = answerRepository.findByUuidAndSchId(answerUuid.trim(), schId).orElse(null); + if (answer == null || Integer.valueOf(1).equals(answer.getIsDeleted())) { + // 不存在与别校的答卷同一口径回答,不给出探测面(uuid 不可枚举,契约 §1) + throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid); + } + return answer; + } + + private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) { + for (Long id : FormValueValidator.attachmentFileIds(valuesJson, schemaJson)) { + if (!fileRepository.findByIdAndSchId(id, schId).isPresent()) { + throw new BizException(ErrorCode.PARAM_ERROR, "附件不存在或不属于本校:id=" + id); + } + } + } + + private void opLog(Long answerId, String op, Integer operatorId, String summary) { + AnswerOpLog entry = new AnswerOpLog(); + entry.setAnswerId(answerId); + entry.setOp(op); + entry.setOperatorId(operatorId); + entry.setSummary(AnswerOpLog.summarize(summary)); + opLogRepository.save(entry); + } + + private List requireBatchIds(List ids) { + if (ids == null || ids.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "批量操作至少要选一条答卷"); + } + if (ids.size() > MAX_BATCH_SIZE) { + throw new BizException(ErrorCode.PARAM_ERROR, "批量操作单次上限 " + MAX_BATCH_SIZE + " 条"); + } + return ids; + } + + /** 逐条隔离:一条失败只记录这条的失败原因,不影响其它条(方案 §5.4"批量逐条走单条校验") */ + private AnswerModels.BatchItemView runOne(String answerUuid, Runnable action) { + try { + action.run(); + return AnswerModels.BatchItemView.ok(answerUuid); + } catch (BizException e) { + return AnswerModels.BatchItemView.fail(answerUuid, e.getMessage()); + } + } + +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java b/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java new file mode 100644 index 0000000..059821e --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java @@ -0,0 +1,104 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.common.FormSchemaValidator; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerIndex; +import com.yunhai.formcenter.repository.AnswerIndexRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * {@code answer_index} 写入器(方案 §5.2 / 契约 §2 searchable)。 + * + *

唯一一条判断口径:只有该答卷钉住版本里显式声明 {@code searchable} 的字段才写行, + * 且列按 type 归位——等值(select/radio/checkbox/multiselect→v_str)、 + * 区间(date→v_date、number→v_num)、前缀模糊(text/textarea/phone→v_str,超 191 截断)。 + * {@code upload/image} 没有定义检索算子(契约 §2),即便被勾了 searchable 也不写行(只 warn)。

+ * + *

三个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})。 + * 不存在"按新版本 schema 清理旧字段行"的入口——那会打掉 V8 的"删字段后历史仍可筛"。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AnswerIndexService { + + private final AnswerIndexRepository indexRepository; + + /** 提交:按钉住版本写行 */ + public void write(Answer answer, String schemaJson) { + JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏"); + Map fields = FormSchemaValidator.fieldsByKeys(schemaJson); + for (String fieldKey : FormSchemaValidator.searchableKeys(schemaJson)) { + JsonNode field = fields.get(fieldKey); + JsonNode value = values.get(fieldKey); + if (field == null || value == null || value.isNull()) { + continue; + } + String type = field.path("type").asText(); + if (isMultiValueType(type)) { + if (!value.isArray()) { + continue; + } + for (JsonNode item : value) { + if (item.isTextual()) { + indexRepository.save(row(answer, fieldKey, item.asText(), null, null)); + } + } + } else if ("number".equals(type)) { + if (value.isNumber()) { + indexRepository.save(row(answer, fieldKey, null, new BigDecimal(value.asText()), null)); + } + } else if ("date".equals(type)) { + if (value.isTextual()) { + indexRepository.save(row(answer, fieldKey, null, null, LocalDate.parse(value.asText()))); + } + } else if ("upload".equals(type) || "image".equals(type)) { + log.warn("[索引] 字段 {} 是 {} 类型,契约未定义检索算子,不写 answer_index 行", fieldKey, type); + } else { + if (value.isTextual()) { + indexRepository.save(row(answer, fieldKey, value.asText(), null, null)); + } + } + } + } + + /** 编辑:全量覆盖式重建(先 bulk 删旧行,再按同一份钉住 schema 重写,方案 §5.4"索引同步重建") */ + public void rebuild(Answer answer, String schemaJson) { + clear(answer.getId()); + write(answer, schemaJson); + } + + /** 软删答卷同步删行(方案 §5.2;idx_ai_answer 就是为这条路径建的) */ + public void clear(Long answerId) { + int removed = indexRepository.deleteByAnswerId(answerId); + log.debug("[索引] answer_id={} 清理索引行 {} 条", answerId, removed); + } + + public List rowsOf(Long answerId) { + return indexRepository.findByAnswerIdOrderByIdAsc(answerId); + } + + private AnswerIndex row(Answer answer, String fieldKey, String vStr, BigDecimal vNum, LocalDate vDate) { + AnswerIndex row = new AnswerIndex(); + row.setAnswerId(answer.getId()); + row.setSchId(answer.getSchId()); + row.setCode(answer.getCode()); + row.setFieldKey(fieldKey); + row.setVStr(AnswerIndex.truncateForIndex(vStr)); + row.setVNum(vNum); + row.setVDate(vDate); + return row; + } + + private static boolean isMultiValueType(String type) { + return "checkbox".equals(type) || "multiselect".equals(type); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java new file mode 100644 index 0000000..73e4cad --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -0,0 +1,258 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.common.FormRuntimeRules; +import com.yunhai.formcenter.common.FormStatusDefs; +import com.yunhai.formcenter.common.FormValueValidator; +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.FillQuota; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormFile; +import com.yunhai.formcenter.enums.FormEnums; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FillQuotaRepository; +import com.yunhai.formcenter.repository.FormFileRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +/** + * 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关: + * + *
+ * 钉版解析 → 截止 → 幂等命中 → subject 授权 → 值校验(按钉住版本) → 附件归属
+ *          → 限次占位(fill_quota 撞键即拒) → 落 answer → 写 answer_index(只写 searchable)
+ * 
+ * + *

为什么顺序是这样:

+ *
    + *
  • 幂等检查必须在限次之前——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";
  • + *
  • 限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插 + * (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);
  • + *
  • 值校验用未展开的 schema 原文 + 字典放行集(含软删条目), + * 而不是 resolve 的内联结果——展开后就看不出这个字段引用了哪个字典, + * "软删条目仍放行"的口径也就无法表达(契约 §2.1)。
  • + *
+ * + *

事务形态:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 + * 目的是让"幂等键撞 uk"这种并发结局能在事务外重新读取原答卷并按契约返回 200+原 answer_id + * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AnswerService { + + /** 答案号日期段格式:{@code {code}-{yyyyMMdd}-{当日序号}}(契约 §5) */ + private static final DateTimeFormatter ANSWER_NO_DAY = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE; + + private final FormDefService defService; + private final FormDictService dictService; + private final FormAdminGuard adminGuard; + private final AnswerRepository answerRepository; + private final FillQuotaRepository quotaRepository; + private final FormFileRepository fileRepository; + private final AnswerIndexService indexService; + private final TransactionTemplate transactionTemplate; + + /** + * 提交答卷。 + * + * @param user CAS 会话用户(sch_id / submitter_id 由它推导,请求体里没有这两个字段——V17) + */ + public AnswerModels.SubmitView submit(BoaUser user, AnswerModels.SubmitRequest req) { + String idempotencyKey = requireIdempotencyKey(req.getIdempotencyKey()); + try { + return transactionTemplate.execute(tx -> doSubmit(user, req, idempotencyKey)); + } catch (DataIntegrityViolationException e) { + // 只剩两种可能:幂等键撞 uk(并发重放,按契约返回原答卷)或 uuid 撞库(32 位随机,实际不可能) + Answer replay = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); + if (replay != null && belongsToCaller(replay, user)) { + log.info("[提交] 幂等键并发命中,返回原答卷 uuid={}", replay.getUuid()); + return toView(replay, true); + } + throw new BizException(ErrorCode.PARAM_ERROR, "提交冲突,请稍后重试(幂等键可能已被占用)"); + } + } + + private AnswerModels.SubmitView doSubmit(BoaUser user, AnswerModels.SubmitRequest req, String idempotencyKey) { + Long schId = requireSessionSchId(user); + Integer submitterId = user.getId(); + String appCode = req.getAppCode() == null || req.getAppCode().trim().isEmpty() + ? DEFAULT_APP_CODE : req.getAppCode().trim(); + // 1) 钉版:与填报页 resolve 同一条遮蔽规则(P20——升版窗口旧版仍可填,天然按旧版落地) + FormDefService.Published published = defService.resolvePublished(schId, appCode, req.getFormCode()); + Form form = published.form(); + String schemaJson = published.versionRow().getSchemaJson(); + + // 2) 截止(datetime 粒度,过期接口拒绝,契约 §5) + if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { + throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode()); + } + + // 3) 幂等命中→200+原 answer_id(在限次之前,见类注释) + Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); + if (existing != null) { + if (!belongsToCaller(existing, user)) { + // 不泄露他人答卷身份:键被他人占用直接判越权,不返回其 answer + throw new BizException(ErrorCode.FORBIDDEN, "幂等键已被其它账号占用"); + } + return toView(existing, true); + } + + // 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人) + Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId); + + // 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 + String valuesJson = valuesToJson(req.getValues()); + FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); + assertAttachmentsOwned(schId, valuesJson, schemaJson); + + // 6) 限次占位(uk 是并发兜底) + String quotaKey = occupyQuota(form, subjectId, submitterId); + + // 7) 落答卷 + Answer answer = new Answer(); + answer.setUuid(newUuid()); + answer.setSchId(schId); + answer.setAppCode(form.getAppCode()); + answer.setCode(form.getCode()); + answer.setVersion(published.versionRow().getVersion()); + answer.setSubmitterId(submitterId); + answer.setSubjectId(subjectId); + answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); + answer.setValuesJson(valuesJson); + answer.setAnswerNo(nextAnswerNo(form)); + answer.setIdempotencyKey(idempotencyKey); + // saveAndFlush:让 uk 撞键在本方法内暴露(外层据 DataIntegrityViolationException 判定并发重放); + // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 + answer = answerRepository.saveAndFlush(answer); + + // 8) 索引:只写 searchable 字段(V3/V9) + indexService.write(answer, schemaJson); + + log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", + answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(), + submitterId, subjectId, quotaKey); + return toView(answer, false); + } + + // ==================== 各环节 ==================== + + private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId) { + if (subjectId == null || subjectId.equals(submitterId)) { + return subjectId; + } + if (!adminGuard.canManage(user)) { + throw new BizException(ErrorCode.FORBIDDEN, + "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")"); + } + return subjectId; + } + + /** 返回占住的 limit_key(null=该表单不限次,未占位) */ + private String occupyQuota(Form form, Integer subjectId, Integer submitterId) { + if (!FormEnums.FillLimit.once(form.getFillLimit())) { + return null; + } + String limitKey = FormRuntimeRules.quotaKey(subjectId, submitterId); + if (quotaRepository.existsByFormIdAndLimitKey(form.getId(), limitKey)) { + throw new BizException(ErrorCode.CUSTOM, "该表单限填一次,已提交过(如需重填请联系管理员删除原提交)"); + } + FillQuota quota = new FillQuota(); + quota.setFormId(form.getId()); + quota.setLimitKey(limitKey); + try { + quotaRepository.saveAndFlush(quota); + } catch (DataIntegrityViolationException e) { + // 并发兜底的正主:两个并发提交在这里只有一个能插进去 + throw new BizException(ErrorCode.CUSTOM, "该表单限填一次,已提交过(并发提交被唯一键拦下)"); + } + return limitKey; + } + + /** 报名号:开关关→null;开→{code}-{yyyyMMdd}-{当日序号}(序号=当日已有号数+1,含软删行不回收号) */ + private String nextAnswerNo(Form form) { + if (form.getAnswerNoEnabled() == null || form.getAnswerNoEnabled() != 1) { + return null; + } + String prefix = form.getCode() + "-" + LocalDate.now().format(ANSWER_NO_DAY) + "-"; + long seq = answerRepository.countTodayAnswerNo(form.getSchId(), form.getAppCode(), form.getCode(), + prefix + "%") + 1; + return prefix + String.format("%04d", seq); + } + + private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) { + List ids = FormValueValidator.attachmentFileIds(valuesJson, schemaJson); + for (Long id : ids) { + FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null); + if (file == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "附件不存在或不属于本校(请先走中心上传接口,值里只存返回的 {id,fileName,key}):id=" + id); + } + } + } + + private String valuesToJson(JsonNode values) { + if (values == null || values.isNull()) { + return "{}"; + } + if (!values.isObject()) { + throw new BizException(ErrorCode.PARAM_ERROR, "values 必须是 JSON 对象 {key:value}"); + } + return values.toString(); + } + + private String requireIdempotencyKey(String raw) { + String clean = raw == null ? null : raw.trim(); + if (clean == null || clean.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "idempotencyKey 必填(客户端生成的 uuid,契约 §5)"); + } + if (clean.length() > 64) { + throw new BizException(ErrorCode.PARAM_ERROR, "idempotencyKey 过长(≤64)"); + } + return clean; + } + + 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()); + } + + private boolean belongsToCaller(Answer answer, BoaUser user) { + // 会话校 + 提交人共同界定"这条幂等记录是不是你的"(uk 是库级的,跨账号命中不能放行) + return answer.getSubmitterId() != null && answer.getSubmitterId().equals(user.getId()) + && user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null + && answer.getSchId() != null && answer.getSchId().longValue() == user.getCurrentRole().getSchId(); + } + + private static String newUuid() { + return UUID.randomUUID().toString().replace("-", "").toLowerCase(); + } + + private AnswerModels.SubmitView toView(Answer answer, boolean replayed) { + AnswerModels.SubmitView view = new AnswerModels.SubmitView(); + view.setAnswerId(answer.getUuid()); + view.setAnswerNo(answer.getAnswerNo()); + view.setReplayed(replayed); + return view; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java index be0b032..1a22552 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java +++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java @@ -10,25 +10,30 @@ import java.util.Arrays; import java.util.List; /** - * 定义域管理面守卫(方案 §6.5:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。 + * 管理面守卫(方案 §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 第②层的权威载体是"授权模块集"(表单设计/数据管理/字典管理模块行), + * 那要等 T4 消费 GetUserMod 授权树后才能实现;在那之前按平台标准角色闸门把关, + * 口径=执行手册 §5 P21 裁决(T2 验收时对 flow-center 模板做的唯一一处收紧):

+ *
    + *
  • 超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN}):可跨校, + * 且是唯一能操作平台级行({@code sch_id=0})的身份;
  • + *
  • 校级:{@code roleId ∈ {2 校领导, 7 管理员}} 限本校—— + * 局领导(5) 不给管理权(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。
  • + *
* - *

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

+ *

{@link #canManage} 是同一个判定的"不抛异常"形态,供契约 §5 的 subject 授权分支使用 + * (普通用户 {@code subject_id} 只能=自己,有管理权者可代填他人,V17)。

*/ @Service public class FormAdminGuard { private static final int SUPER_ADMIN_TYPE = 1; - private static final List ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7); + /** P21 收紧:校级只认 2(校领导)/7(管理员),局领导 5 已移出 */ + private static final List ADMIN_ROLE_IDS = Arrays.asList(2, 7); /** - * 校验定义管理权并返回本次操作的租户域。 + * 校验管理权并返回本次操作的租户域(定义面与数据面共用)。 * * @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行) * @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒 @@ -37,7 +42,7 @@ public class FormAdminGuard { if (user == null || user.getId() == null) { throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); } - if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) { + if (isSuperAdmin(user)) { if (targetSchId != null) { return targetSchId; } @@ -47,22 +52,40 @@ public class FormAdminGuard { } 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 (!hasSchoolAdminRole(user) || current == null) { + throw new BizException(ErrorCode.FORBIDDEN, "无表单管理权限"); + } + if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) { + // 平台级行=跨校资产:校级管理员把目标域传成 0,先按"平台级不归学校管"讲清楚, + // 而不是笼统说一句"只能管本校"(同一条拒绝,原因要说准) + 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; } + /** + * 是否具备管理权(超管或校级管理员)。不含租户域核对—— + * 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。 + */ + public boolean canManage(BoaUser user) { + if (user == null || user.getId() == null) { + return false; + } + return isSuperAdmin(user) || (hasSchoolAdminRole(user) && currentSchId(user) != null); + } + + private boolean isSuperAdmin(BoaUser user) { + return user.getType() != null && user.getType() == SUPER_ADMIN_TYPE; + } + + private boolean hasSchoolAdminRole(BoaUser user) { + return user.getRoleIds() != null && user.getRoleIds().stream().anyMatch(ADMIN_ROLE_IDS::contains); + } + private Long currentSchId(BoaUser user) { if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { return null; diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 9be3ffd..4f1e828 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -55,6 +55,12 @@ public class FormDefService { */ private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-]{1,64}$"); + /** + * 开报名号时的 code 长度上限:{@code -yyyyMMdd-} 占 9 位,序号留到 5 位(单日上万)仍不越界 + * {@code answer_no varchar(64)}(契约 §10)→ 64-9-6=49,留 1 位余量。 + */ + static final int MAX_CODE_LEN_FOR_ANSWER_NO = 49; + /** 新建表单的占位 schema:分组与字段都在草稿里长出来,发布时必过校验器 */ private static final String EMPTY_SCHEMA = "{\"groups\":[]}"; @@ -117,6 +123,20 @@ public class FormDefService { * options 已内联展开(字典不进对外契约,契约 §2.1)。 */ public FormModels.ResolvedFormView resolve(Long schId, String appCode, String code) { + Published published = resolvePublished(schId, appCode, code); + return toResolvedView(published.form(), published.versionRow(), + dictService.expandSchema(published.form().getSchId(), published.versionRow().getSchemaJson())); + } + + /** + * 同一条 resolve 规则的服务内部形态:返回头表 + 钉住的发布版本行(含 schema 原文)。 + * + *

提交路径(T3)必须与填报页加载走同一份遮蔽实现,否则"按加载时解析到的版本校验并钉版" + * 会出现两套口径(契约 §5);值校验要的是未展开的 schema 原文(字典引用按放行集校验, + * 展开后的 options 反而看不出引用了哪个字典)。返回实体只在本服务与答卷服务之间流转, + * 不出响应体(契约 §1:id 绝不外露)。

+ */ + public Published resolvePublished(Long schId, String appCode, String code) { String cleanApp = normalizeAppCode(appCode); String cleanCode = code == null ? null : code.trim(); if (cleanCode == null || cleanCode.isEmpty()) { @@ -129,7 +149,7 @@ public class FormDefService { } if (form.getStatus() == null || form.getStatus() != FormEnums.FormStatus.PUBLISHED.getCode() || form.getCurrentVersion() == null) { - // 业务规则类(契约 §6 末段):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4) + // 业务规则类(契约 §6 定稿映射):草稿/停用都不开放填报入口,但数据面读接口不受影响(方案 §5.4) throw new BizException(ErrorCode.CUSTOM, form.getStatus() != null && form.getStatus() == FormEnums.FormStatus.DISABLED.getCode() ? "表单已停用,不再接收填报:" + cleanCode : "表单尚未发布:" + cleanCode); @@ -137,7 +157,65 @@ public class FormDefService { 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())); + return new Published(form, row); + } + + /** 解析结果对(头表 + 钉住的发布版本行),仅服务层内部使用 */ + public static final class Published { + private final Form form; + private final FormVersion versionRow; + + Published(Form form, FormVersion versionRow) { + this.form = form; + this.versionRow = versionRow; + } + + public Form form() { + return form; + } + + public FormVersion versionRow() { + return versionRow; + } + } + + /** + * 数据面解析答卷钉住的版本行(T3 编辑/软删/改状态用):按同一条校域遮蔽规则取头表, + * 再取该版本行;不校验生命周期——方案 §5.4"停用只断填报入口、数据读改接口不变", + * 升版期间的旧版答卷同样必须可管理。 + * + *

一处歧义窗口按"退回平台级行"处理:答卷的钉版坐标是 {@code (会话校, app_code, code, version)} + * (契约 §1 校域不变式:{@code answer.sch_id} 恒=会话校),若它当初投的是平台级表单、 + * 而本校后来建了同 {@code (app_code, code)} 的表单,遮蔽规则会把解析指向本校表单且版本号可能对不上—— + * 此时退回平台级同 code 表单的版本行,历史答卷仍按原 schema 校验。 + * 根因与替代方案见 T3 报告问题单。

+ */ + public Published pinnedForData(Long schId, String appCode, String code, Integer version) { + String cleanApp = normalizeAppCode(appCode); + // 数据面取域含软删表单:答卷的定义溯源不能因为表单被软删而断掉(无物理删除,方案 §5.4) + Form form = pickByShadowing( + formRepository.findDataDomainCandidates(code, cleanApp, resolveDomain(schId)), schId); + if (form == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + code); + } + FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), version).orElse(null); + if (row == null && form.getSchId() != null && form.getSchId() != Form.PLATFORM_SCH_ID) { + Form platform = formRepository.findBySchIdAndAppCodeAndCode(Form.PLATFORM_SCH_ID, cleanApp, code) + .orElse(null); + if (platform != null) { + FormVersion platformRow = versionRepository.findByFormIdAndVersion(platform.getId(), version) + .orElse(null); + if (platformRow != null) { + form = platform; + row = platformRow; + } + } + } + if (row == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "答卷钉住的版本行不存在:" + cleanApp + "/" + code + " v" + version); + } + return new Published(form, row); } // ==================== 写:草稿/发布/生命周期 ==================== @@ -301,6 +379,15 @@ public class FormDefService { if (req.getAnswerNoEnabled() != 0 && req.getAnswerNoEnabled() != 1) { throw new BizException(ErrorCode.PARAM_ERROR, "answerNoEnabled 只能是 0 或 1"); } + if (req.getAnswerNoEnabled() == 1) { + // 报名号形态 {code}-{yyyyMMdd}-{4 位序号} 必须装得进 answer_no varchar(64)(契约 §10): + // 9 个分隔/日期字符 + 4 位序号占掉 13,code 上限因此是 51。开关处拦住,好过提交当天报库错。 + if (form.getCode().length() > MAX_CODE_LEN_FOR_ANSWER_NO) { + throw new BizException(ErrorCode.PARAM_ERROR, + "开启报名号要求 code ≤" + MAX_CODE_LEN_FOR_ANSWER_NO + " 位(当前 " + + form.getCode().length() + "):" + form.getCode()); + } + } form.setAnswerNoEnabled(req.getAnswerNoEnabled()); } if (req.getGroupTag() != null) { diff --git a/src/main/java/com/yunhai/formcenter/service/FormFileService.java b/src/main/java/com/yunhai/formcenter/service/FormFileService.java new file mode 100644 index 0000000..1a24c0a --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FormFileService.java @@ -0,0 +1,210 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.common.FormValueValidator; +import com.yunhai.formcenter.entity.FormFile; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.repository.FormFileRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * 上传模块(平台标准版,自写——手册 §1 明令"不抄 notice 的 FileController", + * 其反面教材点是本地键与绝对 URL 进值;本实现按契约 §3/方案 §4.3② 只交相对 key)。 + * + *

五条硬约束:

+ *
    + *
  1. 存储根路径读形 {@code ${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}}—— + * {@code FILE_PATH} 由 projman 注入且持久卷固定挂 {@code /data/upload},jar 内只在不注入 env 的本地生效, + * 不可删(notice 的容器锚定丢附件教训,见 application.yml 原样保留的注释);
  2. + *
  3. 返回只有 {@code {id, fileName, key}}:没有 fileUrl——{@code FILE_WEB_PATH} 是环境注入项, + * 绝对地址进答卷=环境耦合,换域名历史附件全断(方案 §4.3②);
  4. + *
  5. key=不可猜测随机串即访问控制(方案 §7.7 一期显式假设):{@code form/{yyyy}/{MM}/{32hex}.{ext}}, + * 存储名与用户文件名无关,同时消灭同名覆盖与路径穿越两类事故;
  6. + *
  7. 扩展名黑名单:平台现行 15 项(svc-hall/UserBase/Notice 同源)+ {@code svg} + * (同源静态直访下 svg 可携带脚本,是"key 即访问控制"这套假设的唯一例外);
  8. + *
  9. 单文件 ≤50MB(方案 §7.7 全局兜底;容器侧上限由 application.yml 的 + * {@code spring.servlet.multipart} 显式配,P12——Spring 默认 1MB 会直接挡掉合法附件)。 + * 每字段 ≤9 个是提交值侧的约束,在 {@link FormValueValidator} 判定。
  10. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FormFileService { + + /** 平台同源危险后缀黑名单(小写、不含点) */ + private static final Set DANGEROUS_EXT = new HashSet<>(Arrays.asList( + "jsp", "jspx", "html", "htm", "js", "exe", "sh", "bat", "php", "asp", "aspx", + "jar", "war", "class", "htaccess", + // 本轮追加:同源静态直访 + 前端 img/链接渲染,svg 可内嵌脚本(方案 §7.7 的访问控制假设兜不住它) + "svg")); + + /** 归一化扩展名形态:小写字母数字 1-15 位(挡掉 ".jpg "、".jsp%00"、双扩展名伪装) */ + private static final java.util.regex.Pattern EXT_PATTERN = + java.util.regex.Pattern.compile("^[a-z0-9]{1,15}$"); + + private static final DateTimeFormatter YEAR = DateTimeFormatter.ofPattern("yyyy"); + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MM"); + private static final char[] HEX = "0123456789abcdef".toCharArray(); + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * 存储根:FILE_PATH(平台注入)> FILE_UPLOAD_PATH(兼容别名)> jar 内本地兜底 > /data/upload。 + * 三层占位是 application.yml 注释里定死的读取形态,勿改成单键。 + */ + @Value("${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}") + private String storeRoot; + + /** 本地兜底键值(仅测试用;生产不注入 env 时也是它) */ + @Value("${file.upload-path:/data/upload}") + private String localFallbackRoot; + + private final FormFileRepository fileRepository; + + @PostConstruct + public void init() { + // 启动即暴露配置问题(路径不可写要在第一次上传前就知道,而不是等用户点了上传才 500) + try { + Files.createDirectories(Paths.get(storeRoot)); + } catch (IOException e) { + throw new IllegalStateException("上传存储根不可用:" + storeRoot, e); + } + log.info("[上传] store_root={}(FILE_PATH 未注入时走本地兜底键 file.upload-path)", storeRoot); + } + + /** 上传:校验 → 落盘(随机名) → 落档建行,返回契约 §3 的三件套 */ + @Transactional + public AnswerModels.FileView upload(BoaUser user, MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空"); + } + if (file.getSize() > FormValueValidator.MAX_FILE_SIZE_BYTES) { + throw new BizException(ErrorCode.PARAM_ERROR, + "单文件不得超过 50MB(当前 " + (file.getSize() / 1024 / 1024) + "MB)"); + } + Long schId = requireSessionSchId(user); + String original = sanitizeName(file.getOriginalFilename()); + String ext = requireSafeExt(original); + String key = "form/" + LocalDate.now().format(YEAR) + "/" + LocalDate.now().format(MONTH) + + "/" + randomHex() + "." + ext; + + Path base = Paths.get(storeRoot).toAbsolutePath().normalize(); + Path target = base.resolve(key).normalize(); + // 双保险:随机 key 本身不含用户输入,这里再保证落点一定在存储根之下 + if (!target.startsWith(base)) { + throw new BizException(ErrorCode.PARAM_ERROR, "存储路径非法,已拒绝"); + } + try { + Files.createDirectories(target.getParent()); + try (InputStream in = file.getInputStream()) { + Files.copy(in, target); + } + } catch (IOException e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "文件写入失败:" + e.getMessage()); + } + + FormFile row = new FormFile(); + row.setSchId(schId); + row.setFileName(original); + row.setStoreKey(key); + row.setFileSize(file.getSize()); + row.setExt(ext); + row = fileRepository.save(row); + + AnswerModels.FileView view = new AnswerModels.FileView(); + view.setId(row.getId()); + view.setFileName(row.getFileName()); + view.setKey(row.getStoreKey()); + return view; + } + + /** 当前生效的存储根(仅测试断言用——日志行含中文,Windows 控制台重定向是 GBK,grep 不可靠) */ + public String getEffectiveStoreRoot() { + return storeRoot; + } + + /** 未注入平台 env 时应当落到的本地兜底根(仅测试断言用) */ + public String getLocalFallbackRoot() { + return localFallbackRoot; + } + + /** 把相对 key 解析成磁盘路径;越出存储根一律返回 null(测试断言与二期清理共用) */ + public Path resolveStored(String key) { + Path base = Paths.get(storeRoot).toAbsolutePath().normalize(); + Path target = base.resolve(key).normalize(); + return target.startsWith(base) ? target : null; + } + + // ==================== 内部 ==================== + + /** 只取用户文件名的最后一段并限长:丢掉任何路径分隔符(zip-slip/穿越面),保留展示名 */ + private String sanitizeName(String originalFilename) { + if (originalFilename == null || originalFilename.trim().isEmpty()) { + return "file"; + } + String name = originalFilename.replace('\\', '/'); + int slash = name.lastIndexOf('/'); + name = slash >= 0 ? name.substring(slash + 1) : name; + name = name.replaceAll("[\\p{Cntrl}]", "").trim(); + if (name.isEmpty()) { + return "file"; + } + return name.length() > 255 ? name.substring(name.length() - 255) : name; + } + + private String requireSafeExt(String fileName) { + int dot = fileName.lastIndexOf('.'); + if (dot < 0 || dot == fileName.length() - 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件缺少扩展名:" + fileName); + } + String ext = fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!EXT_PATTERN.matcher(ext).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, "扩展名不合法:" + ext); + } + if (DANGEROUS_EXT.contains(ext)) { + throw new BizException(ErrorCode.PARAM_ERROR, "不允许上传的文件类型:" + ext); + } + return ext; + } + + private static String randomHex() { + byte[] bytes = new byte[16]; + RANDOM.nextBytes(bytes); + StringBuilder sb = new StringBuilder(32); + for (byte b : bytes) { + sb.append(HEX[(b >> 4) & 0xF]).append(HEX[b & 0xF]); + } + return sb.toString(); + } + + 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/resources/application.yml b/src/main/resources/application.yml index a46e636..84b54da 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,6 +30,15 @@ spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai + # ===== 上传大小上限(T3 上传模块,问题单 P12 记档项)===== + # 必须显式配:Spring Boot 默认 max-file-size=1MB,会把契约 §3 的 ≤50MB 附件在校验器之前就挡掉 + # (报 MultipartException,用户只看到"上传失败")。两处上限与方案 §7.7 全局兜底同口径: + # 单文件 ≤50MB;请求侧同样 50MB——前端逐文件上传(view-ui-plus 默认一次请求一个文件), + # 不给"一个请求塞多个大文件"的放大面。生产若需调整由平台 env 覆盖,不写进 jar。 + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB # ===== 文件上传存储路径 ===== # 教训注释自 notice/service application.yml 原样抄入(2026-10 事故): diff --git a/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java b/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java new file mode 100644 index 0000000..3ab0187 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java @@ -0,0 +1,226 @@ +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.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 提交值校验单测(契约 §3 值形状 + §5 校验条款;脱库脱网可跑)。 + * + *

字典侧取值域由注入的 {@link FormValueValidator.DictCandidates} 提供, + * 服务层接的是 {@code FormDictService#allowedValues}(含软删条目,P18/P19 定稿)—— + * 本测试用假实现把"软删条目仍放行""伪造 value 被拒"两条口径钉住。

+ */ +@DisplayName("FormValueValidator - 契约 §3 值形状") +class FormValueValidatorTest { + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20}," + + "{\"key\":\"reason\",\"label\":\"申报理由\",\"type\":\"textarea\",\"maxLength\":10}," + + "{\"key\":\"mobile\",\"label\":\"手机号\",\"type\":\"phone\",\"pattern\":\"^1[3-9]\\\\d{9}$\"}," + + "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"min\":0,\"max\":100}," + + "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true}," + + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true," + + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}," + + "{\"key\":\"level\",\"label\":\"荣誉级别\",\"type\":\"radio\"," + + "\"options\":{\"source\":\"dict:level\"}}," + + "{\"key\":\"subjects\",\"label\":\"获奖学科\",\"type\":\"checkbox\"," + + "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]}," + + "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"image\",\"maxFiles\":2}," + + "{\"key\":\"files\",\"label\":\"证明材料\",\"type\":\"upload\"}" + + "]}]}"; + + /** level 字典的放行集:含一条"软删条目"legacy(服务层给的就是 allowedValues,含软删) */ + private static final Set LEVEL_VALUES = + new LinkedHashSet<>(Arrays.asList("school", "city", "legacy")); + + private static final FormValueValidator.DictCandidates DICTS = code -> "level".equals(code) + ? new HashSet<>(LEVEL_VALUES) : Collections.emptySet(); + + private static String v(String body) { + return "{" + body + "}"; + } + + /** 契约 §3 示例值的"全类型合法提交"——同时是 V1 的后端接受面 */ + private static String fullValid() { + return v("\"name\":\"张三\",\"reason\":\"多次获得市级三好学生\",\"mobile\":\"13800138000\"," + + "\"score\":95.5,\"birthday\":\"2010-03-09\",\"grade\":\"g2\",\"level\":\"school\"," + + "\"subjects\":[\"phy\",\"math\"]," + + "\"cert\":[{\"id\":1001,\"fileName\":\"cert1.jpg\",\"key\":\"form/2026/10/1001.jpg\"}]"); + } + + @Test + @DisplayName("契约 §3 每类控件的示例值都被接受(V1 的后端接受面)") + void allReleasedTypesAcceptContractExamples() { + assertThatCode(() -> FormValueValidator.validate(fullValid(), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + // 返回值原样回传(不改写用户数据) + assertThat(FormValueValidator.validate(fullValid(), SCHEMA, DICTS)).isEqualTo(fullValid()); + } + + @Test + @DisplayName("必填缺失/纯空白/空数组都算空;非必填留空放行") + void requiredChecks() { + assertThatThrownBy(() -> FormValueValidator.validate(v("\"grade\":\"g2\""), SCHEMA, DICTS)) + .isInstanceOf(BizException.class).hasMessageContaining("姓名").hasMessageContaining("必填"); + assertThatThrownBy(() -> FormValueValidator.validate(v("\"name\":\" \",\"grade\":\"g2\""), SCHEMA, DICTS)) + .hasMessageContaining("必填"); + assertThatThrownBy(() -> FormValueValidator.validate(v("\"name\":\"张三\""), SCHEMA, DICTS)) + .hasMessageContaining("年级").hasMessageContaining("必填"); + // score/birthday/subjects 等非必填全空 → 过 + assertThatCode(() -> FormValueValidator.validate(v("\"name\":\"张三\",\"grade\":\"g2\""), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("number:值形状=number|null;空串被拒、小数位超 precision、越 min/max 都拒") + void numberRules() { + assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":\"95.5\"", "必须是数字"); + assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":95.55", "precision"); + assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":101", "不得大于"); + assertOn("score", "\"name\":\"张三\",\"grade\":\"g2\",\"score\":-1", "不得小于"); + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"score\":null"), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("date:格式定死 YYYY-MM-DD 且必须是真实存在的日期(V4 的根因,区间比较靠 v_date 类型列)") + void dateRules() { + assertOn("birthday", "\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":\"2023-1-9\"", "YYYY-MM-DD"); + assertOn("birthday", "\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":\"2023-02-30\"", "日期不存在"); + assertOn("birthday", "\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":20230109", "YYYY-MM-DD"); + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"birthday\":\"2024-02-29\""), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("text/textarea/phone:maxLength 与 pattern 生效(phone 只按声明的 pattern,不内置号码规则)") + void textRules() { + assertOn("name", "\"name\":\"超出二十个字符的姓名测试超出二十个字符的姓名测试A\",\"grade\":\"g2\"", "长度超过上限"); + assertOn("reason", "\"name\":\"张三\",\"grade\":\"g2\",\"reason\":\"超过十个字符的申报理由文本\"", "长度超过上限"); + assertOn("mobile", "\"name\":\"张三\",\"grade\":\"g2\",\"mobile\":\"23800138000\"", "格式不正确"); + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"mobile\":\"13900139000\""), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("select/radio:存 value;候选外的值(含伪造)被拒") + void singleOptionRules() { + assertOn("grade", "\"name\":\"张三\",\"grade\":\"g9\"", "不在候选列表内"); + assertOn("grade", "\"name\":\"张三\",\"grade\":2", "选项 value 字符串"); + assertOn("level", "\"name\":\"张三\",\"grade\":\"g2\",\"level\":\"province\"", "不在候选列表内"); + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g3\",\"level\":\"city\""), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("字典引用字段:软删条目的 value 仍放行(旧答卷可原样重存),字典不可用时宁可拒也不放行任意值") + void dictSoftDeletedValuesStillPass() { + // legacy 是软删条目:设计器不可新选,但提交校验放行(契约 §2.1 / V38) + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"level\":\"legacy\""), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + + FormValueValidator.DictCandidates gone = code -> Collections.emptySet(); + assertThatThrownBy(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"level\":\"school\""), SCHEMA, gone)) + .isInstanceOf(BizException.class).hasMessageContaining("字典不可用"); + } + + @Test + @DisplayName("checkbox/multiselect:必须数组、元素必须是候选 value") + void multiOptionRules() { + assertOn("subjects", "\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":\"math\"", "必须是选项 value 数组"); + assertOn("subjects", "\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":[\"math\",\"art\"]", + "不在候选列表内"); + assertOn("subjects", "\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":[1]", "数组元素必须是选项 value 字符串"); + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"subjects\":[\"phy\",\"math\"]"), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("V24:附件值只认 {id,fileName,key}——多带 fileUrl 被拒,绝对 URL/绝对路径/上跳 key 都被拒") + void attachmentShapeRules() { + assertThatCode(() -> FormValueValidator.validate( + v("\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + + "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"form/2026/1/a.jpg\"}]"), SCHEMA, DICTS)) + .doesNotThrowAnyException(); + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + + "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"form/2026/1/a.jpg\",\"fileUrl\":\"http://x/a.jpg\"}]", + "契约外字段"); + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + + "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"https://cdn.example/a.jpg\"}]", "相对存储路径"); + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + + "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"/data/upload/a.jpg\"}]", "相对存储路径"); + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + + "[{\"id\":1,\"fileName\":\"a.jpg\",\"key\":\"../../etc/passwd\"}]", "相对存储路径"); + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":[{\"fileName\":\"a.jpg\",\"key\":\"k\"}]", + "id 必须是正整数"); + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":\"http://x/a.jpg\"", "必须是附件数组"); + } + + @Test + @DisplayName("附件数量上限:字段级 maxFiles 生效;未声明时走全局 ≤9(方案 §7.7),声明超 9 也不放宽") + void attachmentCountLimits() { + String three = "[{\"id\":1,\"fileName\":\"a\",\"key\":\"k/a\"}," + + "{\"id\":2,\"fileName\":\"b\",\"key\":\"k/b\"}," + + "{\"id\":3,\"fileName\":\"c\",\"key\":\"k/c\"}]"; + assertOn("cert", "\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + three, "附件数超过上限 2"); + StringBuilder many = new StringBuilder("["); + for (int i = 1; i <= 10; i++) { + many.append(i > 1 ? "," : "").append("{\"id\":").append(i) + .append(",\"fileName\":\"f").append(i).append("\",\"key\":\"k/f").append(i).append("\"}"); + } + many.append("]"); + assertOn("files", "\"name\":\"张三\",\"grade\":\"g2\",\"files\":" + many, "附件数超过上限 9"); + } + + @Test + @DisplayName("提交数据不得夹带 schema 外字段(严格契约);非对象提交被拒;空 values 视为全空") + void documentShapeRules() { + assertOn(null, "\"name\":\"张三\",\"grade\":\"g2\",\"hack\":1", "未知字段"); + assertThatThrownBy(() -> FormValueValidator.validate("[]", SCHEMA, DICTS)) + .isInstanceOf(BizException.class).hasMessageContaining("JSON 对象"); + assertThatThrownBy(() -> FormValueValidator.validate("not json", SCHEMA, DICTS)) + .hasMessageContaining("合法 JSON 对象"); + assertThatThrownBy(() -> FormValueValidator.validate(null, SCHEMA, DICTS)) + .hasMessageContaining("必填"); + } + + @Test + @DisplayName("attachmentFileIds:只为附件类字段抽取引用 id(服务层据此做同校归属校验)") + void extractsAttachmentIdsOnlyFromAttachmentFields() { + List ids = FormValueValidator.attachmentFileIds( + v("\"name\":\"张三\",\"grade\":\"g2\",\"cert\":" + + "[{\"id\":1001,\"fileName\":\"a.jpg\",\"key\":\"k/a.jpg\"}]," + + "\"files\":[{\"id\":1002,\"fileName\":\"b.pdf\",\"key\":\"k/b.pdf\"}," + + "{\"id\":1003,\"fileName\":\"c.pdf\",\"key\":\"k/c.pdf\"}]"), SCHEMA); + assertThat(ids).containsExactly(1001L, 1002L, 1003L); + assertThat(FormValueValidator.attachmentFileIds( + v("\"name\":\"张三\",\"grade\":\"g2\""), SCHEMA)).isEmpty(); + } + + /** 传字段体的"半份文档"(不含外层大括号),断言被拒且 msg 指明原因 */ + private static void assertOn(String fieldKey, String valuesBody, String expectedFragment) { + assertThatThrownBy(() -> FormValueValidator.validate(v(valuesBody), SCHEMA, DICTS)) + .as(fieldKey == null ? valuesBody : "字段 " + fieldKey) + .isInstanceOf(BizException.class) + .hasMessageContaining(expectedFragment); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java new file mode 100644 index 0000000..d15fb3f --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java @@ -0,0 +1,346 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerOpLog; +import com.yunhai.formcenter.entity.AnswerIndex; +import com.yunhai.formcenter.entity.StatusLog; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.repository.AnswerOpLogRepository; +import com.yunhai.formcenter.repository.StatusLogRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +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.Arrays; +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; + +/** + * 答卷管理操作集成测试(真实 MySQL;自验项 V29/V30 + 编排补充的 V14/V15 顺带断言)。 + * + *

权限层(V14"填表人改状态被拒(接口层)"、V29"填表人无编辑/撤回接口可用")在这里断言的是 + * 接口实际走的那条判定:控制器调 {@code FormAdminGuard.requireAdmin} → 无管理权的填表人被拒。 + * "不存在填表人可用的编辑/撤回端点"这一半,由 {@code FormAnswerController} 只有 submit/upload + * 两个端点保证(本轮代码结构即证据,见 T3 报告 §1)。

+ */ +@SpringBootTest +@Transactional +@DisplayName("AnswerAdminService - 编辑/软删/改状态/批量(V29/V30/V14/V15)") +class AnswerAdminIT extends AnswerTestSupport { + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}," + + "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"searchable\":true}," + + "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}" + + "]}]}"; + + private static final String VALUES = "{\"name\":\"张三\",\"score\":95.5,\"remark\":\"原文\"}"; + + @Autowired + private AnswerOpLogRepository opLogRepository; + + @Autowired + private StatusLogRepository statusLogRepository; + + @Autowired + private FormAdminGuard adminGuard; + + // ==================== V29 编辑 ==================== + + @Test + @DisplayName("V29:管理员编辑成功、写 answer_op_log、索引按钉住版本重建;身份与状态列不动") + void adminEditWritesOpLogAndRebuildsIndex() { + String code = "v29_edit"; + publishForm(code, SCHEMA); + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + Integer operator = 10001; + + AnswerModels.EditRequest edit = new AnswerModels.EditRequest(); + edit.setAnswerId(answerId); + edit.setValues(json("{\"name\":\"张三改\",\"score\":88,\"remark\":\"管理员修正\"}")); + adminService.edit(SCH, operator, edit); + + Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get(); + assertThat(stored.getValuesJson()).contains("张三改").contains("88"); + assertThat(stored.getSubmitterId()).isNotEqualTo(operator); // 编辑不改提交人 + assertThat(stored.getStatus()).isEqualTo("pending"); // 编辑不改状态 + + List logs = opLogRepository.findByAnswerIdOrderByIdAsc(stored.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getOp()).isEqualTo(AnswerOpLog.OP_EDIT); + assertThat(logs.get(0).getOperatorId()).isEqualTo(operator); + assertThat(logs.get(0).getSummary()).contains("钉住版本 v1"); + + List keys = indexService.rowsOf(stored.getId()).stream() + .map(AnswerIndex::getFieldKey).collect(Collectors.toList()); + assertThat(keys).contains("name", "score").doesNotContain("remark"); + assertThat(indexService.rowsOf(stored.getId())).filteredOn(r -> "score".equals(r.getFieldKey())) + .extracting(AnswerIndex::getVNum).containsExactly(new java.math.BigDecimal("88")); + } + + @Test + @DisplayName("V29/V8 同族:编辑按钉住的版本校验——升版后新增字段出现在旧答卷上即被拒") + void editValidatesAgainstPinnedVersionNotLatest() { + String code = "v29_pinned"; + String uuid = publishForm(code, SCHEMA); + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(uuid); + save.setSchemaJson(SCHEMA.replace("{\"key\":\"remark\"", + "{\"key\":\"teacher\",\"label\":\"指导教师\",\"type\":\"text\"},{\"key\":\"remark\"")); + defService.save(SCH, save); + defService.publish(SCH, uuid, "加指导教师"); + + AnswerModels.EditRequest withNewField = new AnswerModels.EditRequest(); + withNewField.setAnswerId(answerId); + withNewField.setValues(json("{\"name\":\"张三\",\"score\":95.5,\"teacher\":\"李老师\"}")); + assertThatThrownBy(() -> adminService.edit(SCH, 10001, withNewField)) + .isInstanceOf(BizException.class) + .hasMessageContaining("未知字段"); // v2 的字段不凭空落在 v1 的答卷上 + + // 答卷仍钉在 v1,其原值未被破坏 + Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get(); + assertThat(stored.getVersion()).isEqualTo(1); + assertThat(stored.getValuesJson()).contains("原文"); + } + + @Test + @DisplayName("编辑仍受值契约约束(必填清空、类型错、夹带未知字段都被拒),且被拒时不落 op_log") + void editStillEnforcesValueContract() { + String code = "v29_invalid"; + publishForm(code, SCHEMA); + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + Answer rowId = answerRepository.findByUuidAndSchId(answerId, SCH).get(); + + AnswerModels.EditRequest clearRequired = new AnswerModels.EditRequest(); + clearRequired.setAnswerId(answerId); + clearRequired.setValues(json("{\"score\":95.5}")); + assertThatThrownBy(() -> adminService.edit(SCH, 10001, clearRequired)).hasMessageContaining("必填"); + + AnswerModels.EditRequest badType = new AnswerModels.EditRequest(); + badType.setAnswerId(answerId); + badType.setValues(json("{\"name\":\"张三\",\"score\":\"高分\"}")); + assertThatThrownBy(() -> adminService.edit(SCH, 10001, badType)).hasMessageContaining("必须是数字"); + + assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(rowId.getId())).isEmpty(); + assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson()).contains("原文"); + } + + // ==================== V30 软删 ==================== + + @Test + @DisplayName("V30:软删置 is_deleted、索引行同步清空、写 op_log;无物理删除——行仍在库里;重复删被拒") + void softDeleteIsTheOnlyDeletion() { + String code = "v30_delete"; + publishForm(code, SCHEMA); + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get(); + assertThat(indexService.rowsOf(stored.getId())).isNotEmpty(); + + adminService.softDelete(SCH, 10001, answerId); + + Answer after = answerRepository.findByUuidAndSchId(answerId, SCH).get(); + assertThat(after.getIsDeleted()).isEqualTo(1); + assertThat(indexService.rowsOf(after.getId())).isEmpty(); // 方案 §5.2 索引行生命周期 + assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(after.getId())) + .extracting(AnswerOpLog::getOp).contains(AnswerOpLog.OP_SOFT_DELETE); + // 留痕与答卷行都还在:只有软删标记,没有任何物理删除路径 + assertThat(answerRepository.findById(after.getId())).isPresent(); + + assertThatThrownBy(() -> adminService.softDelete(SCH, 10001, answerId)) + .isInstanceOf(BizException.class).hasMessageContaining("已删除"); + } + + @Test + @DisplayName("跨校答卷定位不到(按 (域, uuid) 查);表单软删不影响其存量答卷的可管理性") + void domainScopingAndSoftDeletedFormStillOwnsAnswers() { + String code = "v30_scope"; + String uuid = publishForm(code, SCHEMA); + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + + assertThatThrownBy(() -> adminService.softDelete(OTHER_SCH, 10001, answerId)) + .isInstanceOf(BizException.class).hasMessageContaining("答卷不存在"); + + defService.softDelete(SCH, uuid); + AnswerModels.EditRequest edit = new AnswerModels.EditRequest(); + edit.setAnswerId(answerId); + edit.setValues(json("{\"name\":\"删表后仍可修正\",\"score\":60}")); + adminService.edit(SCH, 10001, edit); + assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson()).contains("删表后仍可修正"); + } + + // ==================== V14 / V15 改状态 ==================== + + @Test + @DisplayName("V15:改状态写 status_log;任意未废弃 code 之间都可改(系统内不存在转移规则校验)") + void statusChangeHasNoTransitionRules() { + String code = "v15_status"; + String uuid = publishForm(code, SCHEMA); + FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest(); + defs.setUuid(uuid); + defs.setStatusDefs(Arrays.asList(def("pending", "待处理"), def("done", "已处理"), def("back", "退回"))); + defService.updateStatusDefs(SCH, defs); + + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + Answer row = answerRepository.findByUuidAndSchId(answerId, SCH).get(); + + changeStatus(answerId, "back"); + changeStatus(answerId, "pending"); + changeStatus(answerId, "done"); // 倒退也合法:状态是标记不是流转(方案 §5.3 红线) + + List logs = statusLogRepository.findByAnswerIdOrderByIdAsc(row.getId()); + assertThat(logs).extracting(StatusLog::getStatusCode).containsExactly("back", "pending", "done"); + assertThat(logs).extracting(StatusLog::getOperatorId).containsOnly(10001); + assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getStatus()).isEqualTo("done"); + } + + @Test + @DisplayName("改状态的目标值必须在未废弃定义内(已废弃 code 只用于历史回显,V13 由读路径降级)") + void statusMustBeLiveDefinition() { + String code = "v15_live"; + String uuid = publishForm(code, SCHEMA); + FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest(); + defs.setUuid(uuid); + FormModels.StatusDefView retired = def("retired", "已废弃状态"); + retired.setDeleted(true); + defs.setStatusDefs(Arrays.asList(def("pending", "待处理"), retired)); + defService.updateStatusDefs(SCH, defs); + + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + assertThatThrownBy(() -> changeStatus(answerId, "retired")) + .isInstanceOf(BizException.class).hasMessageContaining("不在该表单的可用定义内"); + assertThatThrownBy(() -> changeStatus(answerId, "never_defined")) + .hasMessageContaining("不在该表单的可用定义内"); + assertThatThrownBy(() -> changeStatus(answerId, " ")) + .hasMessageContaining("status 不能为空"); + } + + @Test + @DisplayName("V14:填表人(学生/普通教师)在接口层被守卫拒于改状态之外;管理员放行") + void submitterCannotChangeStatus() { + String code = "v14_gate"; + publishForm(code, SCHEMA); + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + AnswerModels.ChangeStatusRequest req = new AnswerModels.ChangeStatusRequest(); + req.setAnswerId(answerId); + req.setStatus("done"); + + // 控制器走的就是这一行:无管理权的填表人到不了 service + for (BoaUser submitter : Arrays.asList(plainUser(3), plainUser(1))) { + assertThatThrownBy(() -> adminGuard.requireAdmin(submitter, null)) + .isInstanceOf(BizException.class).hasMessageContaining("无表单管理权限"); + } + assertThat(adminGuard.requireAdmin(adminUser(), null)).isEqualTo(SCH); + } + + // ==================== 批量 ==================== + + @Test + @DisplayName("批量逐条:一条一行留痕、逐条校验隔离失败(混入不存在的 id 只让那一条失败)") + void batchIsPerItemValidationAndLogging() { + String code = "v39_batch"; + String uuid = publishForm(code, SCHEMA); + // 状态定义里有 done,批量改状态的目标值才合法 + FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest(); + defs.setUuid(uuid); + defs.setStatusDefs(Arrays.asList(def("pending", "待处理"), def("done", "已处理"))); + defService.updateStatusDefs(SCH, defs); + String first = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + String second = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + + AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest(); + batch.setAnswerIds(Arrays.asList(first, "00000000000000000000000000000000", second)); + List results = adminService.batchChangeStatus(SCH, 10001, withStatus(batch)); + + assertThat(results).hasSize(3); + assertThat(results.stream().filter(AnswerModels.BatchItemView::getSuccess).count()).isEqualTo(2); + AnswerModels.BatchItemView failed = results.get(1); + assertThat(failed.getSuccess()).isFalse(); + assertThat(failed.getAnswerId()).isEqualTo("00000000000000000000000000000000"); + assertThat(failed.getMsg()).contains("答卷不存在"); + + Answer a = answerRepository.findByUuidAndSchId(first, SCH).get(); + Answer b = answerRepository.findByUuidAndSchId(second, SCH).get(); + assertThat(a.getStatus()).isEqualTo("done"); + assertThat(b.getStatus()).isEqualTo("done"); + assertThat(statusLogRepository.findByAnswerIdOrderByIdAsc(a.getId())).hasSize(1); + assertThat(statusLogRepository.findByAnswerIdOrderByIdAsc(b.getId())).hasSize(1); + // 批量通道的 op 值带 batch_ 前缀(契约 §10 的 op 'edit/soft_delete/batch_…' 形态),一条答卷一行 + assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(a.getId())) + .extracting(AnswerOpLog::getOp).containsExactly(AnswerOpLog.OP_BATCH_CHANGE_STATUS); + } + + @Test + @DisplayName("批量入参约束:空集合与超上限(200)直接拒,不进入逐条阶段") + void batchInputGuards() { + AnswerModels.BatchRequest empty = new AnswerModels.BatchRequest(); + empty.setAnswerIds(java.util.Collections.emptyList()); + assertThatThrownBy(() -> adminService.batchSoftDelete(SCH, 10001, empty)) + .isInstanceOf(BizException.class).hasMessageContaining("至少要选一条"); + + AnswerModels.BatchRequest tooMany = new AnswerModels.BatchRequest(); + tooMany.setAnswerIds(java.util.Collections.nCopies(AnswerAdminService.MAX_BATCH_SIZE + 1, "x")); + assertThatThrownBy(() -> adminService.batchSoftDelete(SCH, 10001, tooMany)) + .hasMessageContaining("单次上限"); + } + + @Test + @DisplayName("批量软删同样逐条留痕并写 batch_soft_delete;被软删的答卷不再出现在可读集合(is_deleted=1)") + void batchSoftDeletePerItemLogging() { + String code = "batch_sd"; + publishForm(code, SCHEMA); + String first = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + String second = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + + AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest(); + batch.setAnswerIds(Arrays.asList(first, second)); + assertThat(adminService.batchSoftDelete(SCH, 10001, batch)) + .extracting(AnswerModels.BatchItemView::getSuccess).containsOnly(true); + + for (String id : Arrays.asList(first, second)) { + Answer row = answerRepository.findByUuidAndSchId(id, SCH).get(); + assertThat(row.getIsDeleted()).isEqualTo(1); + assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(row.getId())) + .extracting(AnswerOpLog::getOp).containsExactly(AnswerOpLog.OP_BATCH_SOFT_DELETE); + assertThat(indexService.rowsOf(row.getId())).isEmpty(); + } + } + + // ==================== 小工具 ==================== + + private void changeStatus(String answerId, String status) { + AnswerModels.ChangeStatusRequest req = new AnswerModels.ChangeStatusRequest(); + req.setAnswerId(answerId); + req.setStatus(status); + adminService.changeStatus(SCH, 10001, req); + } + + private AnswerModels.BatchRequest withStatus(AnswerModels.BatchRequest batch) { + batch.setStatus("done"); + return batch; + } + + private FormModels.StatusDefView def(String code, String label) { + FormModels.StatusDefView view = new FormModels.StatusDefView(); + view.setCode(code); + view.setLabel(label); + return view; + } + + private com.fasterxml.jackson.databind.JsonNode json(String body) { + try { + return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(body); + } catch (Exception e) { + throw new IllegalStateException("测试 JSON 写错了:" + body, e); + } + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java new file mode 100644 index 0000000..54cdd26 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java @@ -0,0 +1,203 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.FillQuota; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormVersion; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.repository.AnswerIndexRepository; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FillQuotaRepository; +import com.yunhai.formcenter.repository.FormRepository; +import com.yunhai.formcenter.repository.FormVersionRepository; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +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.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 提交并发兜底取证(真实 MySQL 双线程,自验项 V32 的并发那半)。 + * + *

本类故意不加 {@code @Transactional}:回滚型测试事务里两个"并发"提交其实串行在同一事务上, + * 唯一键竞态造不出来。这里每条提交各走各的事务并真实落库, + * 因此 {@code @AfterEach} 必须定点清理本类自建的数据(租户域 990401,不与其它 IT 交叉)。

+ */ +@SpringBootTest +@DisplayName("AnswerService - 并发兜底(V32:唯一键才是并发下的裁判)") +class AnswerConcurrencyIT extends AnswerTestSupport { + + private static final long SCH_C = 990401L; + private static final String APP = "form-center"; + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}," + + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true," + + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"}]}]}]}"; + private static final String VALUES = "{\"name\":\"并发提交\",\"grade\":\"g2\"}"; + + private final List createdCodes = new ArrayList<>(); + + @Autowired + private AnswerRepository answers; + + @Autowired + private AnswerIndexRepository indexRepository; + + @Autowired + private FillQuotaRepository quotaRepository; + + @Autowired + private FormRepository formRepository; + + @Autowired + private FormVersionRepository versionRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @AfterEach + void purgeOwnData() { + transactionTemplate.executeWithoutResult(tx -> { + for (String code : createdCodes) { + for (Answer answer : answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code)) { + indexRepository.deleteByAnswerId(answer.getId()); + answers.delete(answer); + } + formRepository.findBySchIdAndAppCodeAndCode(SCH_C, APP, code).ifPresent(form -> { + quotaRepository.findByFormId(form.getId()).forEach(quotaRepository::delete); + for (FormVersion row : versionRepository.findByFormIdOrderByVersionDesc(form.getId())) { + versionRepository.delete(row); + } + formRepository.delete(form); + }); + } + createdCodes.clear(); + }); + } + + @Test + @DisplayName("V32:限一次的表单,同一人并发双提交——恰一条成功、恰一行落库、名额恰一行(uk 是裁判)") + void concurrentSubmitsAgainstOnceLimitProduceExactlyOneAnswer() throws Exception { + String code = "conc_quota"; + createdCodes.add(code); + String uuid = publishForm(SCH_C, code, SCHEMA); + FormModels.FormConfigRequest config = new FormModels.FormConfigRequest(); + config.setUuid(uuid); + config.setFillLimit(com.yunhai.formcenter.enums.FormEnums.FillLimit.ONCE.getCode()); + defService.updateConfig(SCH_C, config); + + Outcome[] outcomes = runTwo(() -> { + try { + String answerId = answerService.submit(user(70001, SCH_C, 1), submit(code, VALUES)) + .getAnswerId(); + return new Outcome(answerId, null); + } catch (BizException e) { + return new Outcome(null, e.getMessage()); + } + }); + + List rows = answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code); + assertThat(rows).as("并发双提交只应留下一条答卷").hasSize(1); + assertThat(outcomes[0].answerId != null ^ outcomes[1].answerId != null) + .as("恰一条成功(另一条被限次拦下)").isTrue(); + assertThat(outcomes[0].message + outcomes[1].message).contains("限填一次"); + assertThat(indexRepository.findByAnswerIdOrderByIdAsc(rows.get(0).getId())) + .as("成功那条的索引行照常写入").isNotEmpty(); + Form form = formRepository.findBySchIdAndAppCodeAndCode(SCH_C, APP, code).get(); + assertThat(quotaRepository.findByFormId(form.getId())) + .extracting(FillQuota::getLimitKey).containsExactly("70001"); + } + + @Test + @DisplayName("V32 相邻面:同一幂等键并发双提交——恰一行答卷,且不产生两个不同的 answerId") + void concurrentSubmitsWithSameIdempotencyKeyProduceOneAnswer() throws Exception { + String code = "conc_idem"; + createdCodes.add(code); + publishForm(SCH_C, code, SCHEMA); + String key = newIdemKey(); + + Outcome[] outcomes = runTwo(() -> { + try { + return new Outcome(answerService.submit(user(70002, SCH_C, 1), + submitWithKey(code, key, VALUES)).getAnswerId(), null); + } catch (BizException e) { + return new Outcome(null, e.getMessage()); + } + }); + + List rows = answers.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH_C, APP, code); + assertThat(rows).hasSize(1); + List ids = new ArrayList<>(); + for (Outcome outcome : outcomes) { + if (outcome.answerId != null) { + ids.add(outcome.answerId); + } + } + // 并发下的正确结局可以是"一个真插入 + 另一个撞 uk 后按契约回原答卷"(都 200、同一个 answerId), + // 也可以是一个 200 + 一个冲突提示;但绝不能出现两条答卷或两个不同的 answerId + assertThat(ids).isNotEmpty(); + assertThat(ids).allMatch(id -> id.equals(rows.get(0).getUuid())); + assertThat(rows.get(0).getIdempotencyKey()).isEqualTo(key); + } + + // ==================== 双线程脚手架 ==================== + + /** 两个线程在同一时刻放开跑同一段逻辑 */ + private interface SubmitOne { + Outcome call(); + } + + private static final class Outcome { + private final String answerId; + private final String message; + + private Outcome(String answerId, String message) { + this.answerId = answerId; + this.message = message; + } + } + + private Outcome[] runTwo(SubmitOne action) throws InterruptedException { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + AtomicReference firstRef = new AtomicReference<>(); + AtomicReference secondRef = new AtomicReference<>(); + + Thread one = thread(ready, start, firstRef, action); + Thread two = thread(ready, start, secondRef, action); + one.start(); + two.start(); + ready.await(); + start.countDown(); + one.join(20000); + two.join(20000); + assertThat(one.isAlive() || two.isAlive()).as("线程未在 20s 内收尾,可能有锁等待").isFalse(); + return new Outcome[]{firstRef.get(), secondRef.get()}; + } + + private Thread thread(CountDownLatch ready, CountDownLatch start, + AtomicReference slot, SubmitOne action) { + Thread t = new Thread(() -> { + ready.countDown(); + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + slot.set(action.call()); + }); + t.setDaemon(true); + return t; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java new file mode 100644 index 0000000..9119b04 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java @@ -0,0 +1,399 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.AnswerIndex; +import com.yunhai.formcenter.enums.FormEnums; +import com.yunhai.formcenter.model.AnswerModels; +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.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +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; + +/** + * 提交链路集成测试(真实 MySQL;自验项 V17 / V31 / V32 / V33 的功能面 + 契约 §3 索引落值)。 + * + *

并发兜底(V32 的"并发同键双提交恰一条成功")不在本类——回滚型事务里造不出真实竞态, + * 由 {@code AnswerConcurrencyIT} 用双线程打真库取证。

+ */ +@SpringBootTest +@Transactional +@DisplayName("AnswerService - 提交链路(V17/V31/V32/V33)") +class AnswerSubmitIT extends AnswerTestSupport { + + /** name/score/birthday/grade/subjects 都声明 searchable;remark 故意不声明(V3 的另一半) */ + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"maxLength\":20," + + "\"searchable\":true}," + + "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"searchable\":true}," + + "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true}," + + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true," + + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}," + + "{\"key\":\"subjects\",\"label\":\"学科\",\"type\":\"checkbox\",\"searchable\":true," + + "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]}," + + "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}" + + "]}]}"; + + private static final String VALUES = "{\"name\":\"张三\",\"score\":95.5,\"birthday\":\"2010-03-09\"," + + "\"grade\":\"g2\",\"subjects\":[\"phy\",\"math\"],\"remark\":\"只进值文档,不进筛选\"}"; + + /** 升版后的 schema:多一个 v2 才有的 teacher 字段 */ + private static final String SCHEMA_V2 = SCHEMA.replace( + "{\"key\":\"subjects\"", "{\"key\":\"teacher\",\"label\":\"指导教师\",\"type\":\"text\"}," + + "{\"key\":\"subjects\""); + + // ==================== V17:身份与 subject 授权 ==================== + + @Test + @DisplayName("V17:sch_id/submitter_id 由会话推导;普通用户把 subject 构造成他人被拒、=自己放行") + void subjectAuthorization() { + String code = "v17_form"; + publishForm(code, SCHEMA); + Integer studentId = 20260001; + + AnswerModels.SubmitRequest otherAsSubject = submit(code, VALUES); + otherAsSubject.setSubjectId(20260002); + assertThatThrownBy(() -> answerService.submit(user(studentId, SCH, 3), otherAsSubject)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权为他人填报"); + + AnswerModels.SubmitRequest self = submit(code, VALUES); + self.setSubjectId(studentId); + AnswerModels.SubmitView view = answerService.submit(user(studentId, SCH, 3), self); + Answer stored = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).orElse(null); + assertThat(stored).isNotNull(); + assertThat(stored.getSubmitterId()).isEqualTo(studentId); + assertThat(stored.getSubjectId()).isEqualTo(studentId); + assertThat(stored.getSchId()).isEqualTo(SCH); + assertThat(stored.getVersion()).isEqualTo(1); + assertThat(stored.getStatus()).isEqualTo("pending"); + } + + @Test + @DisplayName("V17 正向:有管理权者可代填他人——submitter=操作人、subject=被代填人(两键各司其职,方案 §6.3)") + void adminCanFillOnBehalf() { + String code = "v17_proxy"; + publishForm(code, SCHEMA); + Integer teacherId = 10001; + Integer studentId = 20269001; + + AnswerModels.SubmitRequest req = submit(code, VALUES); + req.setSubjectId(studentId); + AnswerModels.SubmitView view = answerService.submit(user(teacherId, SCH, 7), req); + + Answer stored = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).get(); + assertThat(stored.getSubmitterId()).isEqualTo(teacherId); + assertThat(stored.getSubjectId()).isEqualTo(studentId); + } + + @Test + @DisplayName("未登录=未登录段、无学校归属身份不能提交(契约 §6 定稿映射)") + void sessionRequired() { + String code = "v17_session"; + publishForm(code, SCHEMA); + assertThatThrownBy(() -> answerService.submit(null, submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("未登录"); + + com.yunhai.platform.utils.model.BoaUser noSchool = new com.yunhai.platform.utils.model.BoaUser(); + noSchool.setId(10001); + assertThatThrownBy(() -> answerService.submit(noSchool, submit(code, VALUES))) + .hasMessageContaining("无学校归属"); + } + + // ==================== V31:幂等 ==================== + + @Test + @DisplayName("V31:同幂等键重复提交只产生一条答卷,第二次返回 200+原 answerId(不是 409)、不覆盖原值") + void idempotencyReturnsOriginalAnswer() { + String code = "v31_form"; + publishForm(code, SCHEMA); + String key = newIdemKey(); + com.yunhai.platform.utils.model.BoaUser student = plainUser(3); + + AnswerModels.SubmitView first = answerService.submit(student, submitWithKey(code, key, VALUES)); + AnswerModels.SubmitView second = answerService.submit(student, + submitWithKey(code, key, "{\"name\":\"李四\",\"grade\":\"g3\"}")); + + assertThat(second.getAnswerId()).isEqualTo(first.getAnswerId()); + assertThat(first.getReplayed()).isFalse(); + assertThat(second.getReplayed()).isTrue(); + List rows = answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getValuesJson()).contains("张三"); + } + + @Test + @DisplayName("幂等键是库级唯一键:别的账号撞上同键不泄露他人答卷,直接判越权(uk 语义的取舍见 T3 报告)") + void idempotencyKeyOwnedByAnotherAccountIsRejected() { + String code = "v31_cross_account"; + publishForm(code, SCHEMA); + String key = newIdemKey(); + answerService.submit(plainUser(3), submitWithKey(code, key, VALUES)); + + assertThatThrownBy(() -> answerService.submit(plainUser(3), submitWithKey(code, key, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("其它账号占用"); + } + + @Test + @DisplayName("幂等键必填且 ≤64;不同键各自成一条") + void idempotencyKeyRequired() { + String code = "v31_key"; + publishForm(code, SCHEMA); + AnswerModels.SubmitRequest blank = submit(code, VALUES); + blank.setIdempotencyKey(" "); + assertThatThrownBy(() -> answerService.submit(plainUser(3), blank)) + .isInstanceOf(BizException.class).hasMessageContaining("idempotencyKey 必填"); + + StringBuilder longKey = new StringBuilder(); + for (int i = 0; i < 65; i++) { + longKey.append('k'); + } + AnswerModels.SubmitRequest tooLong = submit(code, VALUES); + tooLong.setIdempotencyKey(longKey.toString()); + assertThatThrownBy(() -> answerService.submit(plainUser(3), tooLong)).hasMessageContaining("过长"); + + answerService.submit(plainUser(3), submit(code, VALUES)); + answerService.submit(plainUser(3), submit(code, VALUES)); + assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)) + .hasSize(2); + } + + // ==================== V32:限次(功能面;并发面见 AnswerConcurrencyIT) ==================== + + @Test + @DisplayName("V32:限一次——subject 非空按 subject 限(两教师代填同一学生,第二条被拒;换学生仍可填)") + void onceLimitIsPerSubjectWhenSubjectPresent() { + String code = "v32_subject"; + String uuid = publishForm(code, SCHEMA); + config(SCH, uuid, limitOnce()); + Integer student = 20261001; + + AnswerModels.SubmitRequest first = submit(code, VALUES); + first.setSubjectId(student); + answerService.submit(user(10001, SCH, 7), first); + + AnswerModels.SubmitRequest second = submit(code, "{\"name\":\"王五\",\"grade\":\"g2\"}"); + second.setSubjectId(student); + assertThatThrownBy(() -> answerService.submit(user(10002, SCH, 7), second)) + .isInstanceOf(BizException.class).hasMessageContaining("限填一次"); + + AnswerModels.SubmitRequest otherStudent = submit(code, "{\"name\":\"赵六\",\"grade\":\"g3\"}"); + otherStudent.setSubjectId(20261002); + assertThat(answerService.submit(user(10002, SCH, 7), otherStudent).getAnswerId()).isNotBlank(); + } + + @Test + @DisplayName("V32:限一次——subject 为空按 submitter 限(同人第二次被拒);不限次表单不受影响") + void onceLimitFallsBackToSubmitter() { + String limited = "v32_submitter"; + String uuid = publishForm(limited, SCHEMA); + config(SCH, uuid, limitOnce()); + Integer sameUser = 10001; + answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES)); + assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES))) + .hasMessageContaining("限填一次"); + + String unlimited = "v32_unlimited"; + publishForm(unlimited, SCHEMA); + answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES)); + answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES)); + assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", unlimited)) + .hasSize(2); + } + + @Test + @DisplayName("V32 后半:软删后释放名额,可重填(管理员删错单的第一天眼场景,方案 §5.4)") + void softDeleteReleasesQuota() { + String code = "v32_release"; + String uuid = publishForm(code, SCHEMA); + config(SCH, uuid, limitOnce()); + Integer student = 20262001; + AnswerModels.SubmitRequest first = submit(code, VALUES); + first.setSubjectId(student); + AnswerModels.SubmitView view = answerService.submit(user(10001, SCH, 7), first); + + adminService.softDelete(SCH, 10001, view.getAnswerId()); + + AnswerModels.SubmitRequest refill = submit(code, "{\"name\":\"重新填的\",\"grade\":\"g3\"}"); + refill.setSubjectId(student); + AnswerModels.SubmitView refilled = answerService.submit(user(10001, SCH, 7), refill); + assertThat(refilled.getAnswerId()).isNotEqualTo(view.getAnswerId()); + assertThat(answerRepository.findByUuidAndSchId(refilled.getAnswerId(), SCH).get().getIsDeleted()).isZero(); + } + + // ==================== V33:截止与停用 ==================== + + @Test + @DisplayName("V33:截止后提交被拒(业务规则段);未过期放行(只选日期补 23:59:59)") + void deadlineBlocksSubmit() { + String code = "v33_deadline"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormConfigRequest past = new FormModels.FormConfigRequest(); + past.setDeadline("2020-01-01"); + config(SCH, uuid, past); + assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("已过收集截止时间"); + + FormModels.FormConfigRequest future = new FormModels.FormConfigRequest(); + future.setDeadline("2099-01-01"); + config(SCH, uuid, future); + assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId()).isNotBlank(); + } + + @Test + @DisplayName("V33 后半:停用只断填报入口(提交与 resolve 同口径),重新发布可恢复") + void disabledFormClosesFillEntryOnly() { + String code = "v33_disabled"; + String uuid = publishForm(code, SCHEMA); + defService.disable(SCH, uuid); + + assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("已停用"); + + defService.enable(SCH, uuid); + assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId()).isNotBlank(); + } + + // ==================== 钉版(P20)与索引(V3/V9) ==================== + + @Test + @DisplayName("P20 落地:升版窗口按旧版校验并钉旧版;发布后新字段才可用") + void submitPinsCurrentlyPublishedVersion() { + String code = "pin_version"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(uuid); + save.setSchemaJson(SCHEMA_V2); + defService.save(SCH, save); + + AnswerModels.SubmitRequest tooNew = submit(code, + "{\"name\":\"张三\",\"grade\":\"g2\",\"teacher\":\"李老师\"}"); + assertThatThrownBy(() -> answerService.submit(plainUser(3), tooNew)) + .isInstanceOf(BizException.class).hasMessageContaining("未知字段"); + AnswerModels.SubmitView old = answerService.submit(plainUser(3), submit(code, VALUES)); + assertThat(answerRepository.findByUuidAndSchId(old.getAnswerId(), SCH).get().getVersion()).isEqualTo(1); + + defService.publish(SCH, uuid, "加指导教师字段"); + AnswerModels.SubmitView neu = answerService.submit(plainUser(3), + submit(code, "{\"name\":\"张三\",\"grade\":\"g2\",\"teacher\":\"李老师\"}")); + assertThat(answerRepository.findByUuidAndSchId(neu.getAnswerId(), SCH).get().getVersion()).isEqualTo(2); + } + + @Test + @DisplayName("索引只写 searchable 字段(V3):多值一值一行、date 落 v_date、number 落 v_num、文本超 191 截断") + void indexWritesSearchableOnly() { + String code = "index_v3"; + // 这条用例要往 searchable 的文本字段塞 250 字符验证索引截断,因此把 maxLength 放宽(校验器本身另有用例) + publishForm(code, SCHEMA.replace("\"maxLength\":20", "\"maxLength\":1000")); + StringBuilder longName = new StringBuilder(); + for (int i = 0; i < 250; i++) { + longName.append('n'); + } + AnswerModels.SubmitView view = answerService.submit(plainUser(3), submit(code, + "{\"name\":\"" + longName + "\",\"score\":95.5,\"birthday\":\"2010-03-09\"," + + "\"grade\":\"g2\",\"subjects\":[\"phy\",\"math\"],\"remark\":\"不声明就不进索引\"}")); + + Answer answer = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).get(); + List rows = indexService.rowsOf(answer.getId()); + List keys = rows.stream().map(AnswerIndex::getFieldKey).collect(Collectors.toList()); + assertThat(keys).contains("name", "score", "birthday", "grade", "subjects"); + assertThat(keys).doesNotContain("remark"); + assertThat(rows.stream().filter(r -> "subjects".equals(r.getFieldKey())).count()).isEqualTo(2); + assertThat(rows.stream().filter(r -> "birthday".equals(r.getFieldKey())) + .map(AnswerIndex::getVDate).collect(Collectors.toList())) + .containsExactly(LocalDate.of(2010, 3, 9)); + assertThat(rows.stream().filter(r -> "score".equals(r.getFieldKey())) + .map(AnswerIndex::getVNum).collect(Collectors.toList())) + .containsExactly(new BigDecimal("95.5")); + String indexedName = rows.stream().filter(r -> "name".equals(r.getFieldKey())) + .map(AnswerIndex::getVStr).findFirst().get(); + assertThat(indexedName).hasSize(AnswerIndex.MAX_V_STR_LEN); + assertThat(answer.getValuesJson()).contains(longName.toString()); + } + + @Test + @DisplayName("未开报名号时 answer_no 为空;开启后为 {code}-{yyyyMMdd}-{当日序号} 且逐条递增") + void answerNoGeneration() { + String code = "answer_no"; + String uuid = publishForm(code, SCHEMA); + assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerNo()).isNull(); + + FormModels.FormConfigRequest enable = new FormModels.FormConfigRequest(); + enable.setAnswerNoEnabled(1); + config(SCH, uuid, enable); + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerNo()) + .isEqualTo(code + "-" + today + "-0001"); + assertThat(answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerNo()) + .isEqualTo(code + "-" + today + "-0002"); + } + + @Test + @DisplayName("初始状态取该表单状态定义里第一个未废弃 code(默认待处理=pending)") + void initialStatusComesFromFormStatusDefs() { + String code = "status_default"; + String uuid = publishForm(code, SCHEMA); + FormModels.StatusDefView draft = new FormModels.StatusDefView(); + draft.setCode("first_review"); + draft.setLabel("初筛"); + FormModels.StatusDefsRequest defs = new FormModels.StatusDefsRequest(); + defs.setUuid(uuid); + defs.setStatusDefs(Arrays.asList(draft)); + defService.updateStatusDefs(SCH, defs); + + String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId(); + assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getStatus()).isEqualTo("first_review"); + } + + @Test + @DisplayName("提交期值校验按契约拒绝:伪造选项值、缺必填、number 给字符串") + void submitRejectsInvalidValues() { + String code = "bad_values"; + publishForm(code, SCHEMA); + assertThatThrownBy(() -> answerService.submit(plainUser(3), + submit(code, "{\"name\":\"张三\",\"grade\":\"g9\"}"))) + .isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内"); + assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, "{\"grade\":\"g2\"}"))) + .hasMessageContaining("必填"); + assertThatThrownBy(() -> answerService.submit(plainUser(3), + submit(code, "{\"name\":\"张三\",\"grade\":\"g2\",\"score\":\"高分\"}"))) + .hasMessageContaining("必须是数字"); + } + + @Test + @DisplayName("字典引用字段按放行集校验(P18/P19 贯通到提交侧);伪造 value 被拒") + void dictValuesGateAppliesAtSubmit() { + dictService.saveDict(SCH, "level", "荣誉级别"); + dictService.saveItem(SCH, "level", "school", "校级", 1); + String code = "dict_submit"; + publishForm(code, "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"level\",\"label\":\"级别\",\"type\":\"select\",\"required\":true," + + "\"options\":{\"source\":\"dict:level\"}}]}]}"); + + String accepted = answerService.submit(plainUser(3), submit(code, "{\"level\":\"school\"}")) + .getAnswerId(); + assertThat(accepted).isNotBlank(); + assertThatThrownBy(() -> answerService.submit(plainUser(3), submit(code, "{\"level\":\"province\"}"))) + .isInstanceOf(BizException.class).hasMessageContaining("不在候选列表内"); + } + + // ==================== 小工具 ==================== + + private FormModels.FormConfigRequest limitOnce() { + FormModels.FormConfigRequest config = new FormModels.FormConfigRequest(); + config.setFillLimit(FormEnums.FillLimit.ONCE.getCode()); + return config; + } + +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java new file mode 100644 index 0000000..d52edb3 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java @@ -0,0 +1,139 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 答卷域测试脚手架(T3 各 IT 共用):会话用户构造 + 建表单并发布 + 提交入参拼装。 + * + *

身份载体是平台 POJO {@link BoaUser}(真实链路由 CAS 过滤器填充), + * 测试直接造它——这正是 V17 的断言方式:服务签名里只有 {@code BoaUser} 与请求体, + * 请求体没有 {@code schId/submitterId} 字段,身份只能来自会话对象。

+ */ +abstract class AnswerTestSupport { + + /** 测试专用租户域(避开真实开发库数据;IT 结束一律回滚或显式清理) */ + protected static final long SCH = 990301L; + protected static final long OTHER_SCH = 990302L; + + private static final AtomicInteger USER_SEQ = new AtomicInteger(20000); + + @Autowired + protected FormDefService defService; + + @Autowired + protected FormDictService dictService; + + @Autowired + protected AnswerService answerService; + + @Autowired + protected AnswerAdminService adminService; + + @Autowired + protected AnswerRepository answerRepository; + + @Autowired + protected AnswerIndexService indexService; + + // ==================== 会话用户 ==================== + + /** 普通用户(学生 roleId=3 / 教师 roleId=1):无管理权,subject 只能=自己 */ + protected BoaUser plainUser(Integer roleId) { + return user(USER_SEQ.incrementAndGet(), SCH, roleId, 0); + } + + /** 普通登录用户(type=0 非管理员):指定 id 版 */ + protected BoaUser user(Integer id, Long schId, Integer roleId) { + return user(id, schId, roleId, roleId.equals(7) ? 2 : 0); + } + + /** 中心管理员(roleId=7 管理员):有管理权,可代填他人 */ + protected BoaUser adminUser() { + return user(USER_SEQ.incrementAndGet(), SCH, 7, 2); + } + + /** 另一所学校的用户(跨校探测用) */ + protected BoaUser userAtOtherSchool() { + return user(USER_SEQ.incrementAndGet(), OTHER_SCH, 7, 2); + } + + /** 指定 id 的会话用户(并发/限次用例需要"同一个人"稳定复现) */ + protected BoaUser user(Integer id, Long schId, Integer roleId, Integer type) { + BoaUser user = new BoaUser(); + user.setId(id); + user.setType(type); + user.setRoleIds(java.util.Collections.singletonList(roleId)); + BoaRoleInfo current = new BoaRoleInfo(); + current.setRoleId(roleId); + current.setSchId(schId.intValue()); + user.setCurrentRole(current); + return user; + } + + // ==================== 建定义 ==================== + + /** 建表单 → 保存 schema → 发布,返回 uuid */ + protected String publishForm(String code, String schemaJson) { + return publishForm(SCH, code, schemaJson); + } + + protected String publishForm(Long schId, String code, String schemaJson) { + FormModels.FormCreateRequest create = new FormModels.FormCreateRequest(); + create.setCode(code); + create.setName("测试 " + code); + String uuid = defService.create(schId, create).getFormUuid(); + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(uuid); + save.setSchemaJson(schemaJson); + defService.save(schId, save); + defService.publish(schId, uuid, null); + return uuid; + } + + protected void config(Long schId, String formUuid, FormModels.FormConfigRequest config) { + config.setUuid(formUuid); + defService.updateConfig(schId, config); + } + + // ==================== 提交入参 ==================== + + protected AnswerModels.SubmitRequest submit(String formCode, String valuesJson) { + AnswerModels.SubmitRequest req = new AnswerModels.SubmitRequest(); + req.setFormCode(formCode); + req.setIdempotencyKey(newIdemKey()); + req.setValues(readTree(valuesJson)); + return req; + } + + protected AnswerModels.SubmitRequest submitWithKey(String formCode, String idemKey, String valuesJson) { + AnswerModels.SubmitRequest req = submit(formCode, valuesJson); + req.setIdempotencyKey(idemKey); + return req; + } + + protected String newIdemKey() { + return java.util.UUID.randomUUID().toString().replace("-", ""); + } + + protected Answer lastAnswer(String code) { + List rows = answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code); + return rows.isEmpty() ? null : rows.get(rows.size() - 1); + } + + private static com.fasterxml.jackson.databind.JsonNode readTree(String json) { + try { + return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(json); + } catch (Exception e) { + throw new IllegalStateException("测试 JSON 写错了:" + json, e); + } + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java new file mode 100644 index 0000000..fe09da7 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java @@ -0,0 +1,115 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 管理权闸门单测(执行手册 §5 P21 裁决的落地断言,也是 V17/V14/V29 的权限层依据)。 + * + *

口径:超管 {@code type==1}(UserBase {@code UserTypeEnum.SUPER_ADMIN})可跨校且唯一可管 + * {@code sch_id=0} 平台级行;校级限 {@code roleId ∈ {2 校领导, 7 管理员}}—— + * 局领导(5) 被明确移出(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。 + * 这是 T4 换成"授权模块集"口径之前的过渡态。

+ */ +@DisplayName("FormAdminGuard - P21 过渡闸门") +class FormAdminGuardTest { + + private static final long SCH = 880001L; + + private final FormAdminGuard guard = new FormAdminGuard(); + + @Test + @DisplayName("P21 收紧:校领导(2)/管理员(7) 可管且只能管本校;局领导(5) 与教师(1)/学生(3) 都不可管") + void onlyRoleIdsTwoAndSevenManage() { + assertThat(guard.requireAdmin(schoolUser(2), null)).isEqualTo(SCH); + assertThat(guard.requireAdmin(schoolUser(7), null)).isEqualTo(SCH); + + for (int roleId : Arrays.asList(5, 1, 3, 4, 6)) { + assertThatThrownBy(() -> guard.requireAdmin(schoolUser(roleId), null)) + .as("roleId=" + roleId) + .isInstanceOf(BizException.class) + .hasMessageContaining("无表单管理权限"); + assertThat(guard.canManage(schoolUser(roleId))).as("canManage roleId=" + roleId).isFalse(); + } + } + + @Test + @DisplayName("校级管理员指定他校 schId 被拒(含把 schId 传成 0 想碰平台级行)") + void schoolAdminCannotCrossDomain() { + BoaUser admin = schoolUser(7); + assertThatThrownBy(() -> guard.requireAdmin(admin, 999999L)) + .isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校"); + assertThatThrownBy(() -> guard.requireAdmin(admin, 0L)) + .hasMessageContaining("仅超管可维护"); + } + + @Test + @DisplayName("超管可显式指定任意域(含 0=平台级),未指定时回落当前身份学校") + void superAdminCanTargetAnyDomain() { + BoaUser root = new BoaUser(); + root.setId(1); + root.setType(1); + assertThat(guard.requireAdmin(root, 0L)).isEqualTo(0L); + assertThat(guard.requireAdmin(root, SCH)).isEqualTo(SCH); + root.setCurrentRole(role(3, (int) SCH)); + assertThat(guard.requireAdmin(root, null)).isEqualTo(SCH); + assertThat(guard.canManage(root)).isTrue(); + + BoaUser rootWithoutSchool = new BoaUser(); + rootWithoutSchool.setId(1); + rootWithoutSchool.setType(1); + assertThatThrownBy(() -> guard.requireAdmin(rootWithoutSchool, null)) + .hasMessageContaining("超管请显式指定 schId"); + } + + @Test + @DisplayName("无会话=UNAUTHORIZED(不是 FORBIDDEN)——契约 §6 定稿映射的未登录段") + void missingSessionIsUnauthorized() { + assertThatThrownBy(() -> guard.requireAdmin(null, null)) + .isInstanceOf(BizException.class).hasMessageContaining("未登录"); + BoaUser anonymous = new BoaUser(); + assertThatThrownBy(() -> guard.requireAdmin(anonymous, null)).hasMessageContaining("未登录"); + assertThat(guard.canManage(null)).isFalse(); + assertThat(guard.canManage(anonymous)).isFalse(); + } + + @Test + @DisplayName("有当前身份但无学校归属(区域组/根直属)不算管理权——不给出「本校」域") + void noSchoolBindingIsNotAdmin() { + BoaUser user = new BoaUser(); + user.setId(10133); + user.setRoleIds(Collections.singletonList(7)); + user.setCurrentRole(role(7, null)); + assertThatThrownBy(() -> guard.requireAdmin(user, null)).hasMessageContaining("无表单管理权限"); + assertThat(guard.canManage(user)).isFalse(); + + BoaUser full = schoolUser(7); + assertThatCode(() -> guard.requireAdmin(full, SCH)).doesNotThrowAnyException(); + } + + private BoaUser schoolUser(int roleId) { + BoaUser user = new BoaUser(); + user.setId(10133); + user.setType(2); + user.setRoleIds(Arrays.asList(roleId)); + user.setCurrentRole(role(roleId, (int) SCH)); + return user; + } + + private BoaRoleInfo role(Integer roleId, Integer schId) { + BoaRoleInfo info = new BoaRoleInfo(); + info.setRoleId(roleId); + info.setSchId(schId); + return info; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java b/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java new file mode 100644 index 0000000..436059c --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java @@ -0,0 +1,241 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.repository.FormFileRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +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.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +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; + +/** + * 上传模块集成测试(真实 MySQL + 本地临时存储根;编排补充项 V24 的本地可断言部分: + * 非白名单扩展名被拒、返回值只含 {@code {id,fileName,key}}、答卷值不含绝对 URL。 + * "经 FILE_WEB_PATH 可访问"要等 T9 部署环境,本轮不断言)。 + * + *

存储根经 {@code file.upload-path} 指向 {@code target/tmp-upload}: + * {@code ${FILE_PATH:${FILE_UPLOAD_PATH:${file.upload-path:/data/upload}}}} 的第三级兜底, + * 即"未注入平台 env 时读 jar 内本地键"这条真实分支——测试顺手把这条读取形态钉住了。

+ */ +@SpringBootTest +@Transactional +@TestPropertySource(properties = "file.upload-path=target/tmp-upload") +@DisplayName("FormFileService - 上传模块(V24 本地部分)") +class FormFileServiceIT extends AnswerTestSupport { + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"image\",\"maxFiles\":2}," + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}" + + "]}]}"; + + @Autowired + private FormFileService fileService; + + @Autowired + private FormFileRepository fileRepository; + + @Test + @DisplayName("V24:上传成功只回 {id,fileName,key};key 是 form/yyyy/MM/随机串.ext 的相对路径,落盘可见") + void uploadReturnsContractTripleAndStoresUnderRoot() throws Exception { + AnswerModels.FileView view = fileService.upload(user(10001, SCH, 3), + file("证书 照片.JPG", "img/jpeg", "hello".getBytes())); + + JsonNode json = com.yunhai.formcenter.common.FormSchemaValidator.mapper().valueToTree(view); + List fields = new java.util.ArrayList<>(); + json.fieldNames().forEachRemaining(fields::add); + assertThat(fields).containsExactlyInAnyOrder("id", "fileName", "key"); + assertThat(view.getId()).isPositive(); + assertThat(view.getFileName()).isEqualTo("证书 照片.JPG"); + assertThat(view.getKey()) + .matches("^form/\\d{4}/\\d{2}/[0-9a-f]{32}\\.jpg$") + .doesNotContain("http"); + + File stored = fileService.resolveStored(view.getKey()).toFile(); + assertThat(stored).as("落点在存储根之下且文件真实存在").exists(); + assertThat(stored.length()).isEqualTo(5); + + com.yunhai.formcenter.entity.FormFile row = fileRepository.findById(view.getId()).get(); + assertThat(row.getSchId()).isEqualTo(SCH); + assertThat(row.getExt()).isEqualTo("jpg"); + assertThat(row.getStoreKey()).isEqualTo(view.getKey()); + // 库里存的也是相对 key(不是拼好的 URL)——二期换域名不会打断历史附件 + assertThat(row.getStoreKey()).doesNotContain("http"); + } + + @Test + @DisplayName("存储名与用户文件名无关:同名重复上传各得一个随机 key(不覆盖),路径穿越输入被吸收成展示名") + void storedNameIsRandomNotUserControlled() { + AnswerModels.FileView a = fileService.upload(user(10001, SCH, 3), file("a.jpg", "image/jpeg", new byte[]{1})); + AnswerModels.FileView b = fileService.upload(user(10001, SCH, 3), file("a.jpg", "image/jpeg", new byte[]{2})); + assertThat(a.getKey()).isNotEqualTo(b.getKey()); + + AnswerModels.FileView traversal = fileService.upload(user(10001, SCH, 3), + file("..\\..\\windows\\note.txt", "text/plain", new byte[]{3})); + assertThat(traversal.getFileName()).isEqualTo("note.txt"); // 只取最后一段,分隔符与上跳被吸收 + assertThat(traversal.getKey()).startsWith("form/").doesNotContain(".."); + assertThat(fileService.resolveStored(traversal.getKey())).startsWith( + java.nio.file.Paths.get("target/tmp-upload").toAbsolutePath().normalize()); + } + + @Test + @DisplayName("V24:危险扩展名一律拒(平台同源 15 项 + svg);缺扩展名/非法扩展名字符也拒") + void dangerousExtensionsRejected() { + for (String name : Arrays.asList("shell.jsp", "page.html", "tool.exe", "x.htaccess", + "a.war", "b.class", "c.php", "d.aspx", "e.js", "f.sh", "g.bat", "h.jspx", "i.htm", "j.asp", "k.jar")) { + final String fileName = name; + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), + file(fileName, "application/octet-stream", new byte[]{1}))) + .as(fileName) + .isInstanceOf(BizException.class) + .hasMessageContaining("不允许上传的文件类型"); + } + // 本轮追加项:同源静态直访下 svg 可携带脚本,"key 即访问控制"这套假设兜不住它 + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), + file("icon.svg", "image/svg+xml", new byte[]{1}))) + .hasMessageContaining("不允许上传的文件类型:svg"); + + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), file("noext", "application/octet-stream", + new byte[]{1}))).hasMessageContaining("缺少扩展名"); + // 扩展名形态非法(含百分号/空格)直接拒;伪装成 .jpg 的 .jsp 因"取最后一段扩展名+随机存储名"而失效 + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), file("x.jp%67", "image/jpeg", + new byte[]{1}))).hasMessageContaining("扩展名不合法"); + // 伪装双扩展名按最后一段判定,直接拒;即便放行,存储名也是随机串(不带 .jsp),不构成执行面 + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), file("report.pdf.jsp", "application/pdf", + new byte[]{1}))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不允许上传的文件类型:jsp"); + assertThatCode(() -> fileService.upload(user(10001, SCH, 3), file("ok.docx", "application/msword", + new byte[]{1}))).doesNotThrowAnyException(); + } + + @Test + @DisplayName("单文件 ≤50MB(方案 §7.7 全局兜底):超限拒且不留文件行;空文件拒") + void fileSizeCap() { + MultipartFile tooBig = new OversizedFile("big.pdf", 50L * 1024 * 1024 + 1); + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), tooBig)) + .isInstanceOf(BizException.class).hasMessageContaining("不得超过 50MB"); + assertThatThrownBy(() -> fileService.upload(user(10001, SCH, 3), + new MockMultipartFile("file", "empty.jpg", "image/jpeg", new byte[0]))) + .hasMessageContaining("上传文件为空"); + } + + @Test + @DisplayName("未登录与无学校归属身份不能上传(契约 §7 身份行)") + void uploadRequiresSession() { + assertThatThrownBy(() -> fileService.upload(null, file("a.jpg", "image/jpeg", new byte[]{1}))) + .isInstanceOf(BizException.class).hasMessageContaining("未登录"); + BoaUser noSchool = new BoaUser(); + noSchool.setId(1); + assertThatThrownBy(() -> fileService.upload(noSchool, file("a.jpg", "image/jpeg", new byte[]{1}))) + .hasMessageContaining("无学校归属"); + } + + @Test + @DisplayName("存储根读取形态生效:未注入 FILE_PATH/FILE_UPLOAD_PATH 时落到 jar 内本地兜底键(P12/yml 注释那条链)") + void storeRootFollowsDocumentedPlaceholderChain() { + // 本类经 @TestPropertySource 把本地兜底键指到 target/tmp-upload; + // 断言"生效根=兜底根"即证明占位链的最后两级(file.upload-path → 默认值)在真实上下文里被正确解析 + assertThat(fileService.getLocalFallbackRoot()).isEqualTo("target/tmp-upload"); + assertThat(fileService.getEffectiveStoreRoot()).isEqualTo(fileService.getLocalFallbackRoot()); + } + + @Test + @DisplayName("提交值里的附件 id 必须是本校上传过的行(伪造 id 被拒);真实上传的 id 正常入库") + void submittedAttachmentIdsMustBeOwned() { + String code = "attach_submit"; + publishForm(code, SCHEMA); + + AnswerModels.FileView uploaded = fileService.upload(user(10001, SCH, 3), + file("cert.jpg", "image/jpeg", "img".getBytes())); + String realId = String.valueOf(uploaded.getId()); + + assertThatCode(() -> answerService.submit(user(10001, SCH, 3), submit(code, + "{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"cert.jpg\"," + + "\"key\":\"" + uploaded.getKey() + "\"}]}"))) + .doesNotThrowAnyException(); + + assertThatThrownBy(() -> answerService.submit(user(10001, SCH, 3), submit(code, + "{\"name\":\"张三\",\"cert\":[{\"id\":99999999,\"fileName\":\"x.jpg\",\"key\":\"form/2026/1/x.jpg\"}]}"))) + .isInstanceOf(BizException.class).hasMessageContaining("附件不存在或不属于本校"); + + // 绝对 URL 进值也被拒(V24 的"答卷值中不含绝对 URL") + assertThatThrownBy(() -> answerService.submit(user(10001, SCH, 3), submit(code, + "{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"cert.jpg\"," + + "\"key\":\"http://cdn.example/cert.jpg\"}]}"))) + .hasMessageContaining("相对存储路径"); + } + + // ==================== 小工具 ==================== + + private MockMultipartFile file(String name, String contentType, byte[] content) { + return new MockMultipartFile("file", name, contentType, content); + } + + /** 只报体积、不真的占 50MB 内存的超大文件(测大小上限用) */ + private static final class OversizedFile implements MultipartFile { + private final String name; + private final long size; + + private OversizedFile(String name, long size) { + this.name = name; + this.size = size; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return name; + } + + @Override + public String getContentType() { + return "application/octet-stream"; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public long getSize() { + return size; + } + + @Override + public byte[] getBytes() { + return new byte[0]; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public void transferTo(File dest) { + throw new UnsupportedOperationException("不该被调用:体积校验在前"); + } + } + +}