diff --git a/docs/T3-report.md b/docs/T3-report.md index 43ccc8b..41a52f0 100644 --- a/docs/T3-report.md +++ b/docs/T3-report.md @@ -13,7 +13,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) | +| `repository` | 六个同名 `*Repository` | 全部 JPQL/派生查询,**零 native SQL**(P10);**answer 数据无物理删除通道**(P22/V30)——`AnswerRepository` 不提供删行方法;`FillQuotaRepository.releaseQuota` 的删除=**释放名额**(方案 §5.4 点名行为,V32 后半),不是答卷数据删除〔措辞按 T3 验收报告 §4.1 于 T4 更正〕 | | `common` | `FormValueValidator` | 契约 §3 值形状 ×11 放行类的提交期校验;附件值只认 `{id,fileName,key}` 且拒绝对 URL;字典候选由服务层注入(保持可脱库单测) | | `service` | `AnswerService`(提交编排)/ `AnswerIndexService`(索引写/重建/清理)/ `AnswerAdminService`(编辑/软删/改状态/批量)/ `FormFileService`(上传) | 见 §1.2–§1.4 | | `model` | `AnswerModels` | 提交/编辑/批量/上传出入参;请求体**没有** `schId/submitterId` 字段,响应只出现 `answerId`(uuid) | diff --git a/docs/T4-report.md b/docs/T4-report.md new file mode 100644 index 0000000..39a850b --- /dev/null +++ b/docs/T4-report.md @@ -0,0 +1,160 @@ +# T4 执行说明 · 后端·检索统计 + 数据范围 + 导出 + +> 任务书:《表单中心任务书-Qoder-20261014.md》v1.1 §T4|执行日期:2026-10-14|执行方:Qoder +> 输入权威:方案 v1.4(§5.2/§5.5/§6.1/§6.4/§9)+ 契约 v1.0(§2/§4/§8/§9/§10,含 10-14 T3 验收后回写) +> + 执行手册 §5 **P23~P34 裁决**(P25/P33/P34 前置三项、P21 守卫到期替换、P28/P30 归本轮的收紧点) +> + 《表单中心T3验收报告-20261014.md》§5 开工清单与 §4 三处勘误 +> 前置:T1/T2/T3 已验收。本轮对已验收文件的改动**全部是裁决驱动**(§1.6 逐条列明),未重构已验收行为; +> T2 的 67 条 + T3 的 54 条测试原样全绿(`AnswerAdminIT`/`AnswerSubmitIT` 因签名切换做了等义改写,见 §1.6)。 + +--- + +## 1. 做了什么 + +### 1.1 开工前置三项(T3 验收裁决落地,先做) + +| # | 落地点 | 实现 | +| --- | --- | --- | +| **P25** | `Answer.formUuid`(`varchar(32) NOT NULL updatable=false`)+ `sql/t3_answer_domain.sql` 同步加列;`AnswerService.doSubmit` 写入 `resolvePublished` 命中的表单 uuid;**`FormDefService.pinnedForData(formUuid, version)`** 改按 `form_uuid → form(uk_form_uuid) → 版本行` 精确取数 | **删除了 T3 的"本校缺版本行退平台级"回退启发式**(连带删除已无调用方的 `FormRepository.findDataDomainCandidates`)。理由与裁决一致:那组 `(校, app_code, code, version)` 坐标不含"表单归属校",遮蔽歧义时同版本号会**静默按错 schema** 校验/重建索引,比查不到更糟。开发库答卷零行 → 无回填(`ddl-auto=update` 直接加列,实测见 §3) | +| **P33** | 新接口 `SubjectDirectory.existsInSchool(subjectId, schId)` + 生产实现 `PlatformSubjectDirectory`(SDK `OpenApiUserClient.getUserById` → UserBase `GET /oapi/user/byId?id=`,签名由 starter 出站链负责) | 提交(`AnswerService.requireSubject`)与编辑(`AnswerAdminService.assertSubjectStillValid`)两处,**凡代填**(`subject_id` 非空且 ≠ 提交人)判存在性 + 同校归属,不过则 `PARAM_ERROR`。本人自填不查(会话里的人必然存在)。归属判定取 `schools`(全部可选身份学校,本期 UserBase 起填)为主、`school`/`currentRole.schId` 为兜底(`PlatformSubjectDirectoryTest` 逐层断言) | +| **P34** | 两处 `assertAttachmentsOwned`(提交 / 编辑) | 查到 `form_file` 行后比对值里 `key == store_key`、`fileName == file_name`,不一致 `PARAM_ERROR`。配套把 `FormValueValidator.attachmentFileIds` 改为 `attachments()`(返回完整三件套,否则要比 key 就得把值文档再走一遍) | + +外部调用一律收在策略接口后面:业务服务不直连 UserBase 库、不私写签名;本地 IT 不启动 UserBase/CAS,用注入桩取证,真实链路留 T9 联调(§2 环境事实)。 + +### 1.2 P21 守卫到期替换:`FormAdminGuard` 从角色闸门切"授权模块集" + +- 新接口 `ModuleTreeSource`(+ 生产实现 `PlatformModuleTreeSource` 走 SDK `YschBaseOpenApi.getUserModule`,即 UserBase `/oapi/user/modules`),`roleId∈{2,7}` 硬编码**整体替换、不保留回落**; +- 管理权 = 被授权对应模块行,四个 path 常量逐字对齐手册 §2.2 表:`/admin/forms` 定义管理、`/admin/data` 数据管理、`/admin/stats` 统计、`/admin/dicts` 字典管理(`requireAdmin(user, targetSchId, modulePath)` 25 处调用点全部按面归类); +- **超管例外保留**:`type==1` 可跨校且唯一可管 `sch_id=0` —— 那是 UserBase 用户类型不是 roleId,不属于 P21 要移除的硬编码(方案 §6.5"超管不受单校开通约束"); +- `canManage` 同步切口径 = 超管 或 被授权 `/admin/data` 或 `/admin/forms`(代填分支用); +- `requireAdminAny`:统计按手册"可并入数据管理,二选一"放行 `/admin/stats` 或 `/admin/data`; +- **与数据范围正交**:管理权看 `path`、范围看 `customId`,`FormModuleKeys` 把两者分开取(`scopeAnchorIsNotAModuleRow` 用例钉住"path 空锚点不给管理权")。 + +### 1.3 数据范围(契约 §8 / 方案 §6.4) + +``` +FormEnums.DataScope{ ALL, TEACHING_CLASS, SELF_SUBMITTED, ABOUT_ME } // 闭集四枚举 +AnswerScopeImplementor(策略接口,一值一实现器:×3 一期可用 + 任教班级 1.5 占位) +DataScopeService.resolve(会话用户, 表单) → 范围集合 +AnswerScopeQueryBuilder ← 四条读出路径 + 管理动作行级把关的唯一谓词构造器 +``` + +- **矩阵 × audience**:管理员(7)/校领导(2)=全部;教师(1)×面向教师=仅本人提交,×面向学生=任教班级**一期实现器缺席 → 生效仅本人提交**(方案表格明示要防"教师怎么只看到自己"被当 bug 报);学生(3)×面向学生=关于我;家长(4)/工作号(6)/局领导(5) 与未知角色=回落仅本人提交;**全不命中=仅本人提交**。 +- **customId 扫描**:授权模块树里 path 空、`customId=fc:scope:all` 的行 → 升级全部(递归扫描,锚点在子层也算命中)。 +- **多来源取最宽 = 并集**:一个人有多个身份时逐格取默认再 OR(学生"关于我"与教师"仅本人提交"不是包含关系,取单值会丢一格);命中"全部"即收敛。 +- **任教班级**:枚举里有、`selectable()=false`、解析永不产出、被误用时明确抛"一期未开放"(V28 的三半各有断言)。 + +### 1.4 四条铁律的落点(方案 §5.5) + +| 铁律 | 实现 | 取证 | +| --- | --- | --- | +| ① 两段式列表禁 SELECT values | `AnswerScopeQueryBuilder.pageIds` 只 `select a.id`(Criteria 投影,类型上就取不到大列);当页文档走 `AnswerRepository.findLiveByIds` | `AnswerReadIT.listIsTwoPhaseAndLocatorNeverSelectsValuesColumn`——把 Hibernate SQL 接到内存 appender,断言第一段 SQL 不含 `values_json`、第二段才含 | +| ② keyset 仅提交时间序 | 游标恒落 `id`(与 create_time 同序单调),`asc/desc` 只换方向;**没有排序列参数、没有 offset** | `keysetPagesWithoutOverlapAndCursorIsScopeBound`:size=2 翻 3 页不重不漏;游标 uuid 不在可见集内 → `游标不可用` 拒 | +| ③ 查询覆盖索引化 | 列表/计数/导出落 `idx_answer_form_time(sch_id,app_code,code,is_deleted,id)`;筛选落 `answer_index` 三复合索引的子查询;按状态分组落 `idx_answer_form` | DDL 与实体索引逐条对齐(§1.7 记 P35);筛选在 SQL 层完成,多条件 AND 叠加 | +| ④ 统计缓存仅升版失效 + 短 TTL | `AnswerStatsService`:键=`域 + form_uuid + 当前发布 version + 范围签名 + 用户 + 分组字段`,TTL 60s,只缓存无筛选条件的全量统计;**不按数据变更失效** | `statsCacheIsVersionScopedAndNotInvalidatedByNewData`:新答卷落库后 TTL 内统计总数不变(预期行为)、`cached=true`,而列表实时可见新行;换身份不命中同一键 | + +### 1.5 检索 / 统计 / 导出 / oapi + +- **列表**(`AnswerReadService.list`):两段式 + keyset,返回行含 `answerId(uuid)/version/status(+label)/submitterId/subjectId/answerNo/submitTime/values`;`scopes` 一并回传便于前端与验收对照。值文档原样返回,三态空值与已删字段降级由前端做。 +- **详情**(`detail`):uuid 寻址,返回**钉住版本**(`form_uuid+version`)内联展开后的 schema + 值文档;可见性用同一构造器判(不是 Java 里比字段)→ 越权 `FORBIDDEN`。 +- **筛选候选**(`filterFields` + `FormSchemaValidator.filterableFields`):`searchable` 且**有算子**的字段;`upload/image` 排除(**P28**:不发明算子、不写索引行、也不进候选),按它筛选被点名拒绝。既有定义面 `/api/form/admin/filterFields` 同批改为排除附件类。 +- **算子收敛**(`common/FormFilterOperators`):等值(选项类)、前缀模糊(文本类,LIKE 转义 `\ % _`)、闭区间(date→`v_date`、number→`v_num`);类型由 schema 定,形态不对一律 `PARAM_ERROR`(日期严格 `yyyy-MM-dd`,V4 不走字典序)。 +- **统计**(`AnswerStatsService`):总数 / 按状态 / 按 searchable 字段分组(走 `answer_index` + `count(distinct answer_id)`,多值字段一答卷不因多值重复计数);选项桶 label 回查**展开后**的 options。 +- **导出**(`AnswerExportService`):easyexcel 3.3.2 动态表头 + `ExcelWriter` 分批 `write`,批大小 500,游标逐批前移;同步响应写 `HttpServletResponse` 输出流(不套 JSON 信封,`void` 返回);范围=单表单+可选筛选,无行数上限、无异步;列=六列身份/时间 + 当前发布版本字段序,**选项列显 label、多选按 options 定义序、附件列显 fileName**。 +- **软删排除**:`is_deleted=0` 写在构造器里,四条路径与管理动作一律带上(V30 读侧半边)。 +- **oapi**(`OapiFormController` + `OapiFormService`):`GET /oapi/form/schema?schId=&code=[&appCode=]`(内联 options + 每字段 `valueSchema`,`audience` 下发枚举名)与 `GET /oapi/form/answer?schId=&answerId=`(钉住版本 schema + 值文档);沿用 `/oapi/*` 验签墙与 `code=0` 信封;`resolvePublished` 与填报入口同一条遮蔽实现。 + +### 1.6 对本轮之前已验收文件的改动(全部裁决驱动,非重构) + +| 文件 | 改了什么 | 依据 | +| --- | --- | --- | +| `entity/Answer` | + `form_uuid` 列 + `idx_answer_form_time` 索引 | P25;方案 §5.5 铁律③(索引记 P35) | +| `repository/FormRepository` | + `findByUuid`;**删除 `findDataDomainCandidates`**(改按 form_uuid 取数后无调用方) | P25 | +| `repository/AnswerRepository` | + `findLiveByIds`(两段式第二段)、`findLiveByFormUuidAfterId`(升版补建分批扫描);类注释补"四条读路径在构造器不在仓库" | 铁律①/§6.4 红线;V9(记 P36) | +| `repository/AnswerIndexRepository` | + `findFieldKeysByAnswerId`(补建去重) | V9 | +| `service/FormDefService` | `pinnedForData` 换成 `(formUuid, version)` 形态并删平台级回退;新增 `readDomainForm`;`publish` 末尾调用索引补建;`MAX_CODE_LEN_FOR_ANSWER_NO` **行内注释按 49 口径更正**(T3 验收 §4.2 勘误) | P25、P32、V9、勘误② | +| `service/AnswerService` | 提交写 `form_uuid`;`requireSubject` 增存在性+同校;`assertAttachmentsOwned` 增 key/fileName 比对 | P25/P33/P34 | +| `service/AnswerAdminService` | 方法签名 `(..., Integer operatorId, ...)` → **`(..., BoaUser operator, ...)`**;每条动作前过行级范围(**P30 收紧点**);`pinnedForData` 新坐标;P33/P34 两处校验;批量逐条判范围 | P25/P30/P33/P34、V39 范围半边 | +| `service/AnswerIndexService` | `write` 抽出 `rowsFor`;新增 `backfillAfterPublish`(只增不删、幂等、按 id 游标分批) | V9(记 P36) | +| `service/FormAdminGuard` | 角色闸门 → 授权模块集;+`requireAdminAny`/`authorizedPaths` | P21 到期替换 | +| `common/FormValueValidator` | `attachmentFileIds` → `attachments`(返回三件套节点) | P34 | +| `common/FormSchemaValidator` | + `filterableFields`(searchable 且非附件类) | P28 | +| `enums/FormEnums` | + `DataScope` 四值;`Audience.nameOf`(oapi 下发枚举名) | 契约 §8/§4 | +| `controller/api/FormAdminController` | 20 个端点按 `/admin/forms`(定义 14)与 `/admin/dicts`(字典 6)归类;`filterFields` 排除附件类 | P21、P28 | +| `controller/api/FormAnswerAdminController` | 5 个端点改 `/admin/data` 口径并把 `user` 传进服务 | P21、P30 | +| `src/test/.../AnswerTestSupport` | + 两个网关桩(`InMemoryModuleTreeSource`/`InMemorySubjectDirectory`,`@TestConfiguration`+`@Primary`)、`manager()` 助手、roleId=7 默认授权中心模块行 | P21/P33 落地后的测试形态 | +| `src/test/.../{AnswerSubmitIT,AnswerAdminIT,AnswerConcurrencyIT,FormFileServiceIT}` | 调用点随签名改写(**断言语义未变**);`FormAdminGuardTest` 按新口径重写(5→9 条) | 同上 | +| `docs/T3-report.md` | §1.1 "无物理删除"措辞更正 + §2 V32 "两个不同教师"注明实为两个 roleId=7 操作者 | T3 验收 §4 勘误①③ | +| `sql/t3_answer_domain.sql` | answer 加 `form_uuid` 列与 `idx_answer_form_time`,脚本头记 T4 变更说明 | P25/铁律③ | +| `application.yml` | **一字未改**(P8:`/oapi` 真实消费方出现再补 trusted-apps;教训注释原样保留) | P8 | + +### 1.7 环境动作(本机副作用) + +- 复用本地 MySQL 8.0.45(3306)+ 库 `bsch_formcenter`:`ddl-auto=update` 给 `answer` 追加 `form_uuid varchar(32) NOT NULL` 与 `idx_answer_form_time`,`SHOW COLUMNS`/`SHOW INDEX` 实测已生效(§3);当时表零行 → 无回填、无锁表风险。 +- **零残留**:全量 177 条测试跑完后十张表实测 `count(*)=0`(IT 一律 `@Transactional`,并发 IT 靠 `@AfterEach` 定点清理)。 +- 未启动 CAS/UserBase/Redis;未触碰 160 共享环境;`local-debug/t4-read-smoke.sh` 启动冒烟用 `target/form-center.jar` 直跑(无 env 注入,走 yml 本地兜底键)。 + +--- + +## 2. 自验证据(逐 V) + +**门禁**:`mvn -B -o clean package` → **BUILD SUCCESS,Tests run: 177, Failures: 0, Errors: 0, Skipped: 0**,产物 `target/form-center.jar`。 +本轮新增 **56 条**(`DataScopeServiceTest` 15 + `AnswerReadIT` 14 + `FormFilterOperatorsTest` 7 + `FormValueSchemaBuilderTest` 5 + `AnswerExportIT` 4 + `OapiFormServiceIT` 4 + `PlatformSubjectDirectoryTest` 2 + `AnswerSubmitIT` +1 + `FormAdminGuardTest` 5→9),其中 **23 条打真实 MySQL**;T2 的 67 条与 T3 的 54 条一条未改语义仍全绿。 +测试形态按任务书:纯逻辑走 `*Test`(不启 Spring),列表/统计/导出走 `*IT`(真库 + 注入桩顶掉 UserBase 两件事源)。 + +| V | 断言(方案 §9 原文要点) | 证据 | +| --- | --- | --- | +| **V9** | 新增 searchable 字段全程无 DDL,旧数据按新字段筛选正确 | `AnswerReadIT.searchableFieldAddedInV2FiltersOldAnswers`:v1 里 `advisor` 存在但不 searchable → 不在候选;发布 v2 声明 searchable(**零 DDL**)→ 候选出现该字段,且**三条 v1 期写的答卷**按 `advisor` 前缀筛出 `{张三, 代填张三}`、按"张老师"筛出两条教师行。机制=发布时 `backfillAfterPublish` 补索引行(只增不删,与 V8 同向) | +| **V11** | 统计返回总数/按状态/按字段分组,前端不做聚合 | `AnswerReadIT.statsReturnsTotalStatusAndFieldGroups`:`total=5`、`byStatus=[pending/待处理:5]`、`byField(grade)=[g2/高二:3, g3/高三:2]`(label 由 options 回查);带状态过滤时总数与桶一起窄化 | +| **V23** | 关闭 open-in-view 后详情/列表/导出无 `LazyInitializationException` | 三条路径全在 `@SpringBootTest`(`open-in-view=false` 生效的那份配置,`detailReturnsPinnedSchemaAndValues` 显式断言该属性为 false)下跑通;实体无关联映射,谓词与取数均为显式批量 | +| **V27** | 范围贯穿:任一范围身份调统计/导出,结果集 ⊆ 其列表可见集(四路径同一构造器) | `AnswerReadIT.statsAndExportAreSubsetsOfListForEveryIdentity`:对 **6 个身份**(管理员/教师1/教师2/学生1/学生2/局领导)逐个把列表可见集当基准,断言 `stats.total == |列表|`、`按状态求和 == 总数`、`导出行数 == |列表|`、**且导出的 uuid 集合 ⊆ 列表集合**(不只比数值)。`AnswerExportIT.exportIsScopeBoundPerIdentity` 再验教师 3 行=其列表 3 行、外来身份 0 行且仍产出带表头的合法文件 | +| **V28** | 任教班级出现在契约枚举中但一期不可选 | `DataScopeServiceTest.teachingClassIsInEnumButNotSelectable`(枚举四值齐全、`selectableScopes()` 只三个)、`noCombinationResolvesToTeachingClass`(7 角色 × 2 人群全遍历解析不出)、`TeachingClassScopeImplementor` 被误用时抛"一期未开放";`FormAdminGuardTest` 另钉住"锚点不给管理权" | +| **V37** | 导出范围=单表单+筛选、行数=范围总数(非当页)、选项列显 label、附件列显 fileName、受限身份 ⊆ 可见集 | `AnswerExportIT` 四条:25 行数据(页大小 20)导出 **25 行**且表头逐列断言;`select` 出"高二年级"而非 `g2`、多选 `["phy","math"]` 出**定义序**"数学、物理"、`image` 列出"奖状.jpg"(值里无 URL)、第一列是 32 位 uuid;筛选 `grade=g3` 只出高三 3 行、非法筛选 key 被拒不静默导全量;受限身份逐身份子集断言 | +| **V42** | 角色矩阵验默认、customId 授权升级、全不命中回落、任一范围身份统计/导出 ⊆ 列表 | 单元半边 `DataScopeServiceTest`(矩阵逐格 + 并集 + 回落 + 超管 + 锚点递归);接口半边 `AnswerReadIT.matrixResolvesPerContractSectionEight`、`scopeAllAnchorUpgradesTeacher`(教师授权锚点后 `scopes=[ALL]`、列表 5 行)、`statsAndExportAreSubsetsOfListForEveryIdentity` | +| **V30 读侧半边**(编排补充③) | 软删答卷在列表/详情/统计/导出均不可见 | `AnswerReadIT.softDeletedAnswerIsInvisibleOnAllFourPaths`:列表不含、详情 `答卷不存在或已删除`、统计 `total=5`(未删数)、导出 5 行且 uuid 集合不含(T3 已验索引删行与管理侧,本轮补四条读路径) | +| **V39 范围半边**(编排补充①) | 批量软删/改状态同受数据范围约束(与 V27 同构造器) | `AnswerReadIT.detailIsScopeBound`:教师1(**已授权 `/admin/data` 模块行**,能进接口)批量改状态 `[自己的, 别人的]` → 第一条成功、第二条逐条报"不在你的数据范围内";留痕形态与单条一致由 T3 的 `AnswerAdminIT.batchIsPerItemValidationAndLogging` 承担 | +| **V3 筛选候选 / V4 日期 / P28** | 未声明 searchable 不进候选;日期按日期比;附件无算子 | `FormFilterOperatorsTest`(算子逐类、`2023-1-9` 被拒、`upload` 明确"无检索算子"、候选集排除 cert 而索引口径不变)、`AnswerReadIT.operatorsAreTypeBound`(等值/前缀/区间/日期区间在真库上命中正确集合;LIKE 转义后 `%` 作为输入不命中全部)、`attachmentFieldsAreNotFilterable` | +| **V1/V4 契约形状 / §4 下发** | schema 端点下发内联 options + valueSchema | `FormValueSchemaBuilderTest` 5 条(11 类逐类形状、不发明 `multipleOf/uniqueItems`、附件 `additionalProperties:false`、入参不被改写);`OapiFormServiceIT.schemaIsSelfContainedWithInlineOptions`(字典已内联、`dict:grades` 不出现在出参、`audience="STUDENT"`) | +| **V5/V8 相邻面 + P25** | uuid 寻址、id 不外露、历史答卷按钉住版本读改 | `AnswerReadIT.detailReturnsPinnedSchemaAndValues`(升版后详情仍给 v1 形状)、`platformFormShadowedBySchoolFormStillValidatesItsOwnAnswers`(**P25 用例**:平台表单被本校同 code 遮蔽后,历史答卷编辑按原 schema 放行、夹带本校新字段被拒"未知字段")、`AnswerSubmitIT.adminCanFillOnBehalf` 断言 `form_uuid=resolvePublished 命中行` | +| **V17/V18 前置项加固** | 代填 subject 真实且同校 | `AnswerSubmitIT.proxySubjectMustExistInSessionSchool`(查无此人 / 跨校 → `PARAM_ERROR`;同校放行;本人自填不触发查询)、`PlatformSubjectDirectoryTest`(三层归属字段 + fail-closed) | +| **V26 相邻面** | 无授权模块行者后端拒 | `FormAdminGuardTest`(未授权拒、四模块行互不通用、`/admin/stats` 或 `/admin/data` 二选一、跨校与平台级拒绝、无会话=UNAUTHORIZED) | +| **P34** | 附件 key 与 store_key 一致 | `FormFileServiceIT.submittedAttachmentIdsMustBeOwned` 增两例:id 真、key 指向别的相对路径 → "附件 key 与上传记录不一致";fileName 改名 → "附件文件名与上传记录不一致";正向原样转存不受影响 | + +**其它取证** + +| 项 | 结果 | +| --- | --- | +| 启动冒烟 | `bash local-debug/t4-read-smoke.sh` → **9/9 PASS**:health=200、`Started FormCenterApplication`;`/api/form/admin/data/{list,detail,filterFields,stats,export}` 无票均 302→CAS(POST 型用 `-X POST -d '{}'` 打,证明映射存在而非 404);`/oapi/form/{schema,answer}` 401 验签墙 | +| 端点清点 | **37**(按方法级 `@GetMapping/@PostMapping` 逐条计):`FormAdminController` 20 + `FormBaseController` 2 + `FormResolveController` 1 + `FormAnswerController` 2 + `FormAnswerAdminController` 5 = 30(T1~T3 既有)+ `FormAnswerReadController` 5 + `OapiFormController` 2 = **T4 新增 7**。同口径复核 T3 提交(`git grep -c` on `bff0fb1`)是 30 个,而 T3 报告写"28=21+7"——那是按 `Mapping(` 行数计的另一种口径(含类级 `@RequestMapping`、且 21+7≠28),**本轮起以 30/37 这组数为准**(验收侧若沿用了 28 需同步)。全仓 **零 `nativeQuery`**(MySQL 5.7 兼容口径不变,P10) | +| 红线自检 | 四条读出路径 + 管理动作的谓词全部出自 `AnswerScopeQueryBuilder`(`AnswerReadService`/`AnswerStatsService`/`AnswerExportService`/`AnswerAdminService` 四处调用点,均先经 `scopeQuery` 取规格);构造器不接受"跳过范围"开关,空范围直接 `FORBIDDEN` | +| 错误码(P14 定稿四段) | 游标不可见/筛选形态错/分组字段不可用/附件 key 不符/被代填人无效/表单·答卷未找到=`PARAM_ERROR(2)`;未登录=`UNAUTHORIZED(4)`;无模块权·越范围=`FORBIDDEN(3)`;任教班级未开放·范围签名缺失·未发布=`CUSTOM(5)`。无新增枚举 | +| 容量口径 | 列表页上限 100(超出截断);导出批 500;索引补建批 500;统计缓存条目上限 256(超出整体清空)——都是安全阈值,不是契约条款 | + +--- + +## 3. 问题单(本轮登记,供执行手册 §5 裁决;编号接续 P34) + +| # | 问题 | 本轮取值(最保守解读) | 影响面 | +| --- | --- | --- | --- | +| P35 | 契约 §10 的 `idx_answer_form(sch_id,code,status,id)` 对"单表单 + 未删 + 按 id keyset"的列表/导出不是覆盖索引(不含 `app_code`,且 `status` 夹在中间无法跨状态给 id 序) | **新增 `idx_answer_form_time(sch_id,app_code,code,is_deleted,id)`**(纯加项,不改任何既有列/uk/索引语义)。要否并入契约 §10 请裁决;V10 压测时 `EXPLAIN` 复验选择性 | 铁律③;生产 `ddl-auto=none` 需按脚本追加 CREATE INDEX | +| P36 | V9"旧数据按新 searchable 字段筛选"要求历史答卷有索引行,而提交时只按当时钉住版本写行——方案 §5.2/§5.5 未指定补建机制 | **发布时同步补建**(`AnswerIndexService.backfillAfterPublish`:按 `form_uuid` 未删答卷 id 游标分批、只插缺失 field_key、只增不删、幂等)。代价记账=大表单升版发布多一次全量值文档扫描(2 万级秒级);若裁决改异步任务,改点在 `FormDefService.publish` 一处。不做半成品双路径 | V9;升版耗时;R12 引用影响面 | +| P37 | 方案 §5.5 点名"大表单全量分组统计加 **Redis** 缓存",本轮实现为进程内 `ConcurrentHashMap` + TTL | 进程内缓存:键含 `form_uuid+version+范围签名`、仅升版换键失效、TTL 60s、条目上限 256、只缓存无筛选的全量统计。**不改失效语义**(铁律④完全一致),改的是载体:本地无 Redis 即可跑通门禁,且少一个跨请求共享状态;多实例各自缓存对"容忍 N 分钟延迟"无实质影响 | 若要求一期就上 Redis,替换 `AnswerStatsService` 的 `cache` 字段即可(键与失效策略照用) | +| P38 | 契约 §8"多来源取最宽"在 `仅本人提交` 与 `关于我` 两个不可比较值之间取哪个,文档未写 | **取并集(谓词 OR)**,多身份用户两格都可见;命中 `全部` 即收敛。理由:单值取舍会丢掉一整格(学生+教师双身份者看不到"关于我"),而并集是"最宽"的字面实现且不引入包含关系误判 | 双身份用户的可见集;V42 断言形态 | +| P39 | 铁律②游标是库内 `answer_id`,但 V5 要求 `id` 不出现在任何响应外层 | 对外游标=**当页末行 uuid**,服务端按 `uk_answer_uuid` 点查换成 id,且游标本身也过范围(不可见即拒)。每页多一次索引点查 | URL 形态(T8 翻页参数用 `cursor`=uuid) | +| P40 | 契约 §9 的 schema 端点只写 `?schId=&code=`,快应用表单(`app_code≠form-center`)无法按该形状取 | 加**可选 `appCode`**(不传=form-center,缺省行为与契约文本一致)。是否回写契约 §9 请裁决 | L2 消费方接快应用表单时 | +| P41 | 契约 §9 说 answer 返回"钉住版本的 schema 引用",但 §9 的 schema 端点只下发**当前发布版**——只给引用则消费方拿不到当时那张表 | data 里同时带 `schemaRef{formUuid,appCode,code,version}` 与**钉住版本内联展开后的 `groups`**(自包含,同 §4 原则)。要"只给引用"就删 `groups` 字段,但消费方需另开一个按版本取 schema 的端点 | 对外载荷体积;快照语义 | +| P42 | P33 校验依赖 UserBase,**不可达时**按"查不到"还是"跳过校验"未写 | **fail-closed**:不可达与查不到同样 `PARAM_ERROR`(日志分档便于排查,同 flow-center `requireUserName` 口径)。代填是低频动作,宁可让管理员重试 | UserBase 故障时管理员代填不可用;真实链路 T9 验 | +| P43 | 导出的列集合与"提交人/被代填人"显示形态,方案 §5.5/§6.4 未写 | 列=当前发布版本 schema 字段序(历史版本独有字段不出列,但该行仍在、身份列完整);两个身份列导出 **id 而非姓名**(姓名需按行批量查 UserBase,一期不引 N+1 远程调用)。要否"最新 schema ∪ 历史 searchable"请裁决 | V37 列形态;T9 压测的导出耗时 | +| P44 | 管理权与数据范围都来自 UserBase 模块树,一个读请求最多两次 `byId/modules` 调用 | 一期不做缓存(每请求 1~2 次签名调用),与 `FormBaseController` 现行形态一致。压测暴露再谈短 TTL 缓存或控制器内一次取树两处复用 | 列表/统计 p95(V10 阈值 1.5s/3s)——**T9 关注项** | +| P45 | 手册 §2.2 说统计模块行"可并入数据管理,二选一",接口按哪个判 | 新增 `requireAdminAny`,`/admin/stats` 或 `/admin/data` **任一命中**即放行统计接口(其余接口仍单行精确判) | 学校侧模块行配法自由度 | +| P46 | `canManage`(代填权)在"授权模块集"口径下该覆盖哪几行,P21 只说"整体替换" | 超管 或 `/admin/data` 或 `/admin/forms`(与 P21 期 roleId∈{2,7} 等宽,避免替换口径顺手收窄已验收行为)。只授权 `/admin/dicts` 者**不能**代填(已测) | V17/V32 的代填分支 | + +`trusted-apps` 本轮**未动**(P8:真实消费方出现再补)。yml 与 T3 验收时逐字一致。 + +--- + +## 4. 推送与 CI + +(待回填:提交号、推送区间、Gitea Actions run 结论。) + +—— 全文完 —— diff --git a/sql/t3_answer_domain.sql b/sql/t3_answer_domain.sql index 0e2f2be..2ef7435 100644 --- a/sql/t3_answer_domain.sql +++ b/sql/t3_answer_domain.sql @@ -12,11 +12,16 @@ -- 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 逐列比对一致。 +-- T4 变更(本轮):①answer 增列 `form_uuid VARCHAR(32) NOT NULL`(P25 裁决落地,钉版溯源精确坐标); +-- ②answer 增索引 `idx_answer_form_time`(方案 §5.5 铁律③,四读出路径 keyset 游标的覆盖索引)。 +-- 开发库 answer 当时零行 → NOT NULL 无需回填;已建库由 ddl-auto=update 追加列与索引, +-- 生产(ddl-auto=none)升级即照本脚本这两条 ALTER/CREATE INDEX 执行。 -- ============================================================ CREATE TABLE `answer` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `uuid` VARCHAR(32) NOT NULL COMMENT '数据面运行时身份(详情 URL/嵌入地址/L2 引用,契约 §1)', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '提交时解析命中的表单 uuid——钉版溯源的精确坐标(P25 裁决:遮蔽歧义根治,读改路径一律按它取版本行)', `sch_id` BIGINT NOT NULL COMMENT '恒=会话校(校域不变式:跨校提交在解析层即不可能)', `app_code` VARCHAR(64) NOT NULL, `code` VARCHAR(64) NOT NULL, @@ -37,7 +42,8 @@ CREATE TABLE `answer` ( 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`) + KEY `idx_answer_subject` (`subject_id`), + KEY `idx_answer_form_time` (`sch_id`, `app_code`, `code`, `is_deleted`, `id`) COMMENT 'T4 铁律③:单表单列表/导出/计数 keyset 游标的覆盖索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='答卷(方案 §5.1:定义与答卷物理分离)'; CREATE TABLE `answer_index` ( diff --git a/src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java b/src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java new file mode 100644 index 0000000..6437176 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java @@ -0,0 +1,234 @@ +package com.yunhai.formcenter.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.model.ReadModels; +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.DateTimeParseException; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 检索算子收敛(契约 §2 最后一句):算子按字段 type 定,不给自由表达式—— + * 等值(选项类)、区间(date/number)、前缀模糊(文本类)。 + * + *
调用方只能提交 {@code {key,value}} 或 {@code {key,from,to}};本类按钉住当前发布版本的 + * 字段类型决定它落到 {@code answer_index} 的哪一列(v_str / v_num / v_date)、用哪种算子, + * 形态不对就拒("给 text 传 from/to"、"给 date 传区间外的串"、"给 number 传非数字"都不是筛选语义, + * 是查询注入的入口)。类型正确性在这里保证,V4 的"日期按日期比、不走字典序"也在这里保证。
+ */ +public final class FormFilterOperators { + + /** 算子 */ + public enum Kind { + /** 等值(选项类,多值字段一值一行故命中任一项即算匹配) */ + EQ, + /** 前缀模糊(文本类;v_str 已截 191,超长部分不可筛,契约 §10 认可) */ + PREFIX, + /** 闭区间(date / number) */ + RANGE + } + + /** 落到的索引列 */ + public enum Column { + STR, NUM, DATE + } + + private static final Set数值/日期恒为闭区间:等值就是两端同值的区间,因此 {@code numFrom/numTo} + * (或 {@code dateFrom/dateTo})总是成对非空——构造器只用一个 {@code between} 分支, + * 不给"漏判一种算子"留余地。文本类才有 {@code strValue}(等值或前缀)。
+ */ + public static final class Spec { + private final String key; + private final Kind kind; + private final Column column; + private final String strValue; + private final BigDecimal numFrom; + private final BigDecimal numTo; + private final LocalDate dateFrom; + private final LocalDate dateTo; + + private Spec(String key, Kind kind, Column column, String strValue, + BigDecimal numFrom, BigDecimal numTo, LocalDate dateFrom, LocalDate dateTo) { + this.key = key; + this.kind = kind; + this.column = column; + this.strValue = strValue; + this.numFrom = numFrom; + this.numTo = numTo; + this.dateFrom = dateFrom; + this.dateTo = dateTo; + } + + public String getKey() { + return key; + } + + public Kind getKind() { + return kind; + } + + public Column getColumn() { + return column; + } + + public String getStrValue() { + return strValue; + } + + public BigDecimal getNumFrom() { + return numFrom; + } + + public BigDecimal getNumTo() { + return numTo; + } + + public LocalDate getDateFrom() { + return dateFrom; + } + + public LocalDate getDateTo() { + return dateTo; + } + } + + /** 该类型的算子名(筛选候选下发给前端,前端按它出控件) */ + public static String operatorOf(String type) { + if (OPTION_TYPES.contains(type)) { + return Kind.EQ.name(); + } + if (TEXT_TYPES.contains(type)) { + return Kind.PREFIX.name(); + } + if ("number".equals(type) || "date".equals(type)) { + return Kind.RANGE.name(); + } + return null; + } + + /** + * 该类型落地的索引列(分组统计按它取 {@code v_*} 列);无算子类型返回 null(不可分组)。 + * 与 {@link #resolve} 的列选择是同一条判断的两种问法,放在这里防两处口径分叉。 + */ + public static Column columnOf(String type) { + if (OPTION_TYPES.contains(type) || TEXT_TYPES.contains(type)) { + return Column.STR; + } + if ("number".equals(type)) { + return Column.NUM; + } + if ("date".equals(type)) { + return Column.DATE; + } + return null; + } + + /** + * 解析一个筛选条件。 + * + * @param field schema 里的字段节点(含 type) + * @param item 请求侧条件 + * @throws BizException PARAM_ERROR——类型无算子、形态不匹配、值解析不出正确类型 + */ + public static Spec resolve(JsonNode field, ReadModels.FilterItem item) { + String type = field.path("type").asText(); + String key = item == null ? "" : item.getKey(); + if (OPTION_TYPES.contains(type)) { + return new Spec(key, Kind.EQ, Column.STR, requireText(field, item), null, null, null, null); + } + if (TEXT_TYPES.contains(type)) { + return new Spec(key, Kind.PREFIX, Column.STR, requireText(field, item), null, null, null, null); + } + if ("number".equals(type)) { + BigDecimal from = parseNumber(field, item.getFrom()); + BigDecimal to = parseNumber(field, item.getTo()); + if (from == null && to == null) { + // 单值给成 value 也算等值("分数=95.5"是常见问法),落到同一列的数值区间 + BigDecimal eq = parseNumber(field, item.getValue()); + if (eq == null) { + throw fail(field, key, "number 筛选需要 value 或 from/to"); + } + from = eq; + to = eq; + } else if (from == null || to == null) { + // 只给一端:另一端补成同值——半开区间不是契约给的算子,不做"默认为无穷"的猜测 + BigDecimal bound = from == null ? to : from; + from = bound; + to = bound; + } + return new Spec(key, from.compareTo(to) == 0 ? Kind.EQ : Kind.RANGE, Column.NUM, + null, from, to, null, null); + } + if ("date".equals(type)) { + LocalDate from = parseDate(field, item.getFrom()); + LocalDate to = parseDate(field, item.getTo()); + if (from == null && to == null) { + LocalDate eq = parseDate(field, item.getValue()); + if (eq == null) { + throw fail(field, key, "date 筛选需要 value 或 from/to(yyyy-MM-dd)"); + } + from = eq; + to = eq; + } else if (from == null || to == null) { + LocalDate bound = from == null ? to : from; + from = bound; + to = bound; + } + return new Spec(key, from.equals(to) ? Kind.EQ : Kind.RANGE, Column.DATE, + null, null, null, from, to); + } + // upload/image/user/users/address:契约 §2 没给算子(附件那条另见 P28)——不发明算子 + throw fail(field, key, "该类型一期无检索算子,不能筛选:" + type); + } + + private static String requireText(JsonNode field, ReadModels.FilterItem item) { + String value = item == null ? null : item.getValue(); + if (value == null || value.trim().isEmpty()) { + throw fail(field, item == null ? "" : item.getKey(), "筛选值不能为空"); + } + return value.trim(); + } + + private static BigDecimal parseNumber(JsonNode field, String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + try { + return new BigDecimal(raw.trim()); + } catch (NumberFormatException e) { + throw fail(field, field.path("key").asText(), "不是数值:" + raw); + } + } + + private static LocalDate parseDate(JsonNode field, String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(raw.trim()); + } catch (DateTimeParseException e) { + throw fail(field, field.path("key").asText(), "日期格式应为 yyyy-MM-dd:" + raw); + } + } + + private static BizException fail(JsonNode field, String key, String reason) { + return new BizException(ErrorCode.PARAM_ERROR, "筛选条件[" + key + "](" + + field.path("label").asText(field.path("type").asText()) + ")" + reason); + } +} diff --git a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java new file mode 100644 index 0000000..f85b27f --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java @@ -0,0 +1,55 @@ +package com.yunhai.formcenter.common; + +import com.yunhai.platform.utils.model.BoaModule; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。 + * + *模块行有两种身份,必须分开取:
+ *纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。
+ */ +public final class FormModuleKeys { + + private FormModuleKeys() { + } + + /** 授权页面路由集合(path 空的权限锚点不在内) */ + public static Set依据执行手册 §5 P28 裁决:契约 §2 只给三类算子(等值/区间/前缀模糊),附件没有算子, + * 索引侧"不写行只 warn"(T3 已落)、筛选候选侧"不进候选"(本轮)。两处同一个理由, + * 不能让前端拿到一个勾了 searchable 的附件字段、提交筛选后恒返回空集。
+ */ + public static Map生成依据是契约 §3 的值形状表,逐类对照:文本类 {@code string}(带 maxLength/pattern)、 + * 数值 {@code number}(带声明过的 min/max)、日期 {@code string+format:date}、 + * 单选 {@code string+enum}、多选 {@code array(items=string+enum)}、 + * 附件 {@code array(items=object{id,fileName,key})}(一期值里没有 URL,故 properties 就这三件、 + * {@code additionalProperties:false})。字典引用字段在生成前必须已内联展开(契约 §2.1), + * enum 因此来自展开后的 options。
+ * + *不做的事:不发明契约里没有的约束(不给 number 造 {@code multipleOf}、 + * 不给多选加 {@code uniqueItems});一期未放行的类型(address/user/users)不给形状—— + * 它们在发布校验就被拦下,出现在这里只会误导消费方按二期语义接线。
+ */ +public final class FormValueSchemaBuilder { + + private FormValueSchemaBuilder() { + } + + /** + * 生成该字段的 valueSchema 片段。 + * + * @param field 已内联展开 options 的字段节点 + * @return 片段;一期未放行的类型返回 null(调用方不写该键) + */ + public static ObjectNode valueSchema(JsonNode field) { + if (field == null) { + return null; + } + ObjectMapper mapper = FormSchemaValidator.mapper(); + String type = field.path("type").asText(); + ObjectNode node = mapper.createObjectNode(); + switch (type) { + case "text": + case "textarea": + case "phone": + node.put("type", "string"); + putIfNumber(node, "maxLength", field.get("maxLength")); + putIfText(node, "pattern", field.get("pattern")); + return node; + case "number": + node.put("type", "number"); + putIfNumber(node, "minimum", field.get("min")); + putIfNumber(node, "maximum", field.get("max")); + return node; + case "date": + node.put("type", "string"); + node.put("format", "date"); + return node; + case "select": + case "radio": + node.put("type", "string"); + putEnum(node, field.get("options")); + return node; + case "checkbox": + case "multiselect": { + node.put("type", "array"); + ObjectNode item = mapper.createObjectNode(); + item.put("type", "string"); + putEnum(item, field.get("options")); + node.set("items", item); + return node; + } + case "upload": + case "image": { + node.put("type", "array"); + ObjectNode file = mapper.createObjectNode(); + file.put("type", "object"); + ObjectNode properties = mapper.createObjectNode(); + properties.set("id", typed(mapper, "integer")); + properties.set("fileName", typed(mapper, "string")); + properties.set("key", typed(mapper, "string")); + file.set("properties", properties); + ArrayNode required = file.putArray("required"); + required.add("id"); + required.add("fileName"); + required.add("key"); + file.put("additionalProperties", false); + node.set("items", file); + putIfNumber(node, "maxItems", field.get("maxFiles")); + return node; + } + default: + // address/user/users(二期)与未知类型:不给机读形状,也不瞎造 + return null; + } + } + + /** + * 整棵 schema 加工(契约 §4 的 groups 形状):每个字段挂上 valueSchema,返回深拷贝, + * 不改写入参(版本行 schema 原文逐字节冻结,V7)。 + */ + public static JsonNode withValueSchemas(JsonNode root) { + ObjectMapper mapper = FormSchemaValidator.mapper(); + ObjectNode copy = root == null || !root.isObject() + ? mapper.createObjectNode() : (ObjectNode) root.deepCopy(); + for (JsonNode group : copy.path("groups")) { + if (!group.isObject()) { + continue; + } + for (JsonNode field : group.path("fields")) { + if (!field.isObject()) { + continue; + } + ObjectNode valueSchema = valueSchema(field); + if (valueSchema != null) { + ((ObjectNode) field).set("valueSchema", valueSchema); + } + } + } + return copy; + } + + private static ObjectNode typed(ObjectMapper mapper, String type) { + return mapper.createObjectNode().put("type", type); + } + + private static void putEnum(ObjectNode node, JsonNode options) { + if (options == null || !options.isArray() || options.size() == 0) { + return; + } + ArrayNode enumArray = node.putArray("enum"); + for (JsonNode option : options) { + String value = option.path("value").asText(null); + if (value != null) { + enumArray.add(value); + } + } + if (enumArray.size() == 0) { + node.remove("enum"); + } + } + + private static void putIfNumber(ObjectNode node, String key, JsonNode value) { + if (value != null && value.isNumber()) { + node.set(key, value); + } + } + + private static void putIfText(ObjectNode node, String key, JsonNode value) { + if (value != null && value.isTextual() && !value.asText().isEmpty()) { + node.put(key, value.asText()); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java b/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java index c2a571a..e0bb73e 100644 --- a/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java +++ b/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java @@ -92,14 +92,19 @@ public final class FormValueValidator { return valuesJson; } - /** 提交值里引用的附件 id(服务层据此做同校归属校验,防把别人的 key 塞进自己的答卷) */ - public static List返回整对象而不只返回 id:服务层的归属校验除"id 必须存在且属本校"外,还要比对 + * {@code key==form_file.store_key}(P34 裁决)——只给 id 就得把值文档再走一遍。
+ */ + public static List这里没有编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用" - * 的实现形态就是不存在这些端点,加上管理权守卫给出的 403)。改状态同理:填表人在接口层即被拒(V14)。
- * - *也不提供列表/详情读接口:那四条读出路径(列表/详情/统计/导出)必须共用同一个 scope 构造器 - * (方案 §6.4 红线,V27/V30),归 T4 一次建成,避免本轮先写一个没有范围过滤的读接口、T4 再补一遍。
+ * 的实现形态就是不存在这些端点,加上数据管理模块行守卫给出的 403)。改状态同理: + * 填表人在接口层即被拒(V14)。 * + *两层权限(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data}, + * 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次行级数据范围 + * (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动, + * 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。
+ *{@code schId} 参数只是超管跨校的目标域(校级传别的值即拒,同 {@link FormAdminController} 口径); * 答卷定位按 {@code (域, uuid)},跨校答卷查不到。
*/ @@ -33,13 +36,13 @@ public class FormAnswerAdminController { private final FormAdminGuard adminGuard; private final AnswerAdminService adminService; - /** 编辑值文档:按该答卷钉住的 version 校验(V8 同族),写 answer_op_log,索引同步重建 */ + /** 编辑值文档:按该答卷钉住的 version 校验(P25 坐标=form_uuid+version),写 op_log、索引重建(V29) */ @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); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + adminService.edit(user, domain, body); } /** 软删(唯一删除形态,无物理删除接口——V30);同步删索引行并释放限次名额 */ @@ -47,8 +50,8 @@ public class FormAnswerAdminController { 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); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + adminService.softDelete(user, domain, answerId); } /** 改状态:填表人不可用(V14);写 status_log,系统内无任何转移规则校验(V15) */ @@ -56,17 +59,17 @@ public class FormAnswerAdminController { 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); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + adminService.changeStatus(user, domain, body); } - /** 批量软删:逐条单条校验与留痕,每条独立事务,结果逐条返回 */ + /** 批量软删:逐条单条校验与留痕(V39),每条独立事务,逐条判范围、逐条返回结果 */ @PostMapping("/batch/softDelete") public List权限两层各管一件事,缺一不可(方案 §6.1 三层模型的第②③层):
+ *所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校—— + * 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。
+ * + *身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径); + * 响应只出现 uuid(V5)。列表是两段式的对外形态、导出是流式 xlsx(不走统一 JSON 信封, + * 见 {@link #export} 注释)。
+ */ +@Slf4j +@RestController +@RequestMapping("/api/form/admin/data") +@RequiredArgsConstructor +public class FormAnswerReadController { + + private final FormAdminGuard adminGuard; + private final AnswerReadService readService; + private final AnswerStatsService statsService; + private final AnswerExportService exportService; + + /** 两段式列表(keyset 游标对外是 uuid,排序恒为提交时间序) */ + @PostMapping("/list") + public ReadModels.ListView list(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody ReadModels.ListRequest body) { + requireFormUuid(body.getFormUuid()); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + return readService.list(user, domain, body); + } + + /** 详情(uuid 寻址):行 + 钉住版本的内联展开 schema;不在范围内=FORBIDDEN(与列表同一构造器判) */ + @GetMapping("/detail") + public ReadModels.DetailView detail(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestParam("answerId") String answerId) { + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + return readService.detail(user, domain, answerId); + } + + /** 筛选候选列(V3 + P28:searchable 且有检索算子的字段,附件类不进候选) */ + @GetMapping("/filterFields") + public List响应头里文件名只用 code(URL-safe 子集,P16 定稿)拼,避免中文文件名在各浏览器上的编码分支; + * 行数=该身份范围下的总数(V37),受范围约束由 {@code AnswerExportService} 与读路径同源保证。
+ */ + @PostMapping("/export") + public void export(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody ReadModels.QueryRequest body, + HttpServletResponse response) throws IOException { + requireFormUuid(body.getFormUuid()); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + String fileName = "form-answers.xlsx"; + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Content-Disposition", + "attachment; filename=" + fileName + "; filename*=UTF-8''" + encode(fileName)); + try (OutputStream out = response.getOutputStream()) { + long written = exportService.export(user, domain, body, out); + log.info("[导出] schId={} formUuid={} 行数={}", domain, body.getFormUuid(), written); + } + } + + private static void requireFormUuid(String formUuid) { + if (formUuid == null || formUuid.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空(数据面按表单 uuid 寻址,契约 §1)"); + } + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return value; + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java new file mode 100644 index 0000000..deff4d6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java @@ -0,0 +1,48 @@ +package com.yunhai.formcenter.controller.oapi; + +import com.yunhai.formcenter.model.OapiModels; +import com.yunhai.formcenter.service.OapiFormService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的 + * {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。 + * + *+ * GET /oapi/form/schema?schId=&code=[&appCode=] → data=内联 options + valueSchema 的机读 schema(契约 §4) + * GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9) + *+ * + *
信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调 + * (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。
+ * + *{@code appCode} 是 {@code /schema} 的可选参数(契约 §9 只写 schId+code): + * 不传=form-center(中心自己的表单),传=取该快应用归属下的同 code 表单。 + * 加它是为了让快应用表单也能被对外读到,且不改变缺省行为——登记执行手册 §5 问题单。
+ */ +@RestController +@RequestMapping("/oapi/form") +@RequiredArgsConstructor +public class OapiFormController { + + private final OapiFormService formService; + + /** 机读 schema(内联 options + valueSchema) */ + @GetMapping("/schema") + public OapiModels.SchemaView schema(@RequestParam("schId") Long schId, + @RequestParam("code") String code, + @RequestParam(value = "appCode", required = false) String appCode) { + return formService.schema(schId, appCode, code); + } + + /** 单条答卷(钉住版本 schema 引用 + 值文档) */ + @GetMapping("/answer") + public OapiModels.AnswerView answer(@RequestParam("schId") Long schId, + @RequestParam("answerId") String answerId) { + return formService.answer(schId, answerId); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java index 3160d04..f420952 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Answer.java +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -14,9 +14,10 @@ import javax.persistence.UniqueConstraint; * *三条钉死的事实:
*为什么不能只靠 {@code (sch_id, app_code, code, version)}:那组坐标不含"表单归属校", + * 平台级表单(sch_id=0)被本校后建的同 {@code (app_code, code)} 表单遮蔽后就出现歧义窗口—— + * 遮蔽规则会把解析指向本校表单,版本号若恰好对得上,历史答卷会被静默按错 schema + * 校验/重建索引(比查不到更糟)。按 form_uuid → form(uk_form_uuid) → 版本行取数没有这个窗口: + * uuid 跨版本不变(契约 §1),遮蔽与升版都不影响它指向的表单。
+ */ + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + @Column(name = "sch_id", nullable = false, updatable = false) private Long schId; diff --git a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java index a300d7f..ee698cd 100644 --- a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java +++ b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java @@ -59,6 +59,18 @@ public final class FormEnums { } return false; } + + /** 对外下发用枚举名(契约 §4 的 {@code "audience":"STUDENT"}),不外露数字 */ + public static String nameOf(Integer code) { + if (code != null) { + for (Audience a : values()) { + if (a.code == code) { + return a.name(); + } + } + } + return null; + } } /** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */ @@ -87,4 +99,38 @@ public final class FormEnums { public enum LimitScope { SUBJECT, SUBMITTER } + + /** + * 数据范围(契约 §8 / 方案 §6.4):闭集四枚举,一期契约即含全部四值—— + * 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。 + * + *{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK): + * 枚举里有、一期不可选(V28),其实现器只是占位。
+ */ + public enum DataScope { + ALL("全部"), + TEACHING_CLASS("任教班级"), + SELF_SUBMITTED("仅本人提交"), + ABOUT_ME("关于我"); + + private final String label; + + DataScope(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + /** 枚举名解析(对外只暴露名字,数字序号不外露——加值时不破坏兼容) */ + public static DataScope of(String name) { + for (DataScope scope : values()) { + if (scope.name().equalsIgnoreCase(name == null ? null : name.trim())) { + return scope; + } + } + return null; + } + } } diff --git a/src/main/java/com/yunhai/formcenter/model/OapiModels.java b/src/main/java/com/yunhai/formcenter/model/OapiModels.java new file mode 100644 index 0000000..a563c43 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/OapiModels.java @@ -0,0 +1,72 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +/** + * 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。 + * + *信封 {@code {code,msg,data}} 与 {@code code=0} 由平台 starter 的统一响应链负责(同 flow-center + * {@code /oapi/flow/**} 先例),本层只交 data 载荷;因此这里不出现任何"未找到"专用码—— + * 未找到按契约 §6 定稿映射走 {@code PARAM_ERROR(2)} + msg。
+ * + *不外露的三样:库内 {@code id}(契约 §1/V5)、字典本身({@code options} 一律内联展开,契约 §2.1)、 + * 附件绝对 URL(只有值里的 {@code {id,fileName,key}},V24)。
+ */ +public final class OapiModels { + + private OapiModels() { + } + + /** {@code GET /oapi/form/schema} 的 data:机读 schema(内联 options + 每字段 valueSchema) */ + @Getter + @Setter + public static class SchemaView { + private String formUuid; + private Integer version; + private String code; + private String appCode; + /** STUDENT / TEACHER(枚举名下发,不外露数字,契约 §4 示例) */ + private String audience; + /** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */ + private JsonNode groups; + } + + /** {@code GET /oapi/form/answer} 的 data:钉住版本的 schema 引用 + 值文档(契约 §9) */ + @Getter + @Setter + public static class AnswerView { + private String answerId; + private String formUuid; + private String appCode; + private String code; + /** 该答卷钉住的版本(不随升版改变) */ + private Integer version; + private String status; + private Integer submitterId; + private Integer subjectId; + private String answerNo; + private String submitTime; + /** 值文档原样(三态空值/已删字段的降级由消费方按 schema 判,契约 §3) */ + private JsonNode values; + /** 钉住版本的 schema 引用(消费方要"当时那张表"就按这组坐标取,见 {@link #groups}) */ + private SchemaRef schemaRef; + /** + * 钉住版本的内联展开 schema(同 §4 形状但不含 valueSchema)。 + * 为什么带上:§9 的 schema 端点下发的是当前发布版本,答卷钉的可能是历史版本—— + * 只给引用不给内容,消费方拿不到"当时那张表",快照语义就落空(契约 §9"快照请消费方值拷贝")。 + */ + private JsonNode groups; + } + + /** schema 坐标(不含库内 id) */ + @Getter + @Setter + public static class SchemaRef { + private String formUuid; + private String appCode; + private String code; + private Integer version; + } +} diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java new file mode 100644 index 0000000..67814eb --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java @@ -0,0 +1,194 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.common.FormFilterOperators; +import com.yunhai.formcenter.enums.FormEnums.DataScope; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。 + * + *三条对外纪律:
+ *{@code filters} 装的是已按字段类型收敛过的 {@link FormFilterOperators.Spec}—— + * 请求里的裸条件(字符串三元组)在服务层解析成 Spec,构造器因此不认识 schema、也不给注入面。
+ */ + @Getter + @Setter + public static class Query { + private Long schId; + private String appCode; + private String code; + private Set