Files

179 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
| 仓 | 提交 | 推送 | CI run | 结论 |
| --- | --- | --- | --- | --- |
| form-center-service | `49b095e`(57 条路径:34 新增 + 23 修改,逐条 `git add`,清单见 §1.6/§1.7) | `b2778e5..49b095e main -> main` | [run 1493](http://192.168.10.160:3000/form-center/form-center-service/actions/runs/1493)(head_sha `49b095eb812a…`,`display_title` 即本次提交标题) | **status=completed / conclusion=success**(Gitea Actions API 实测,HTTP 200) |
| form-center-service | `2224523`(docs-only,只改本文) | `49b095e..2224523 main -> main` | [run 1494](http://192.168.10.160:3000/form-center/form-center-service/actions/runs/1494) | **completed / success** |
| form-center-front | 无改动 | 未推送 | — | T4 是纯后端任务 |
回填到此为止:本次 docs 提交(`2224523`)自身触发的 run 1494 已是终态,其后再不追加自指回填提交。
- 暂存方式:**按显式路径逐条 `git add`**(57 条),提交前 `git status` 核对无残留未跟踪项、无 `add -A`、无敏感文件;
`target/`(含 jar 与冒烟临时 json)与 `local-debug/` 都**不在本仓工作树**——`local-debug/` 在
`form-center/` 根,而仓库根是 `form-center/service`,`git check-ignore` 直接报 "outside repository"。
因此本轮新增的冒烟脚本 `local-debug/t4-read-smoke.sh` **不入库**(沿 T1~T3 同一事实,执行手册 §4.2
要求的"脚本入 local-debug"落在该目录本身,入库需另建前端/脚本仓或调整仓库根,不在本轮擅自动)。
- 取证方式:`git credential fill` 会拉起 GCM 图形框(本会话非交互直接超时),故改从
`~/.git-credentials` 取 `192.168.10.160` 这条已存凭据调 `GET /api/v1/repos/.../actions/runs[/{id}]`,
凭据不回显、不落盘、不入库(脚本产物 `target/runs.json`、`target/run1493.json` 已被 `.gitignore` 排除)。
- CI 只跑 `mvn -B -DskipTests clean package`(**不跑单测**,方案 §7.2),所以 CI 绿只证明产物链可构建;
读路径/范围/导出的行为证据在本文 §2 的本地 `mvn test`(177 条,其中 23 条打真实 MySQL)。
- 本节的回填本身是一次 docs-only 提交(只改本文,不动任何构建输入),其后同流水线再跑一次。
—— 全文完 ——