四条读出路径(列表/详情/统计/导出)与管理动作行级把关共用 AnswerScopeQueryBuilder 一个谓词构造器(契约 §8 红线);数据范围按 SysRoleEnum 矩阵×audience 解析 + customId=fc:scope:all 扫描授权模块树,多来源取并集、全不命中回落仅本人提交。 四铁律落地:两段式列表禁 SELECT values_json、keyset 仅提交时间序(游标对外是 uuid)、 覆盖索引化(新增 idx_answer_form_time)、统计缓存键含 form_uuid+version 且仅升版失效+短 TTL。 T3 验收裁决前置三项:P25 answer.form_uuid 精确钉版溯源(删平台级回退启发式)、 P33 代填 subject 存在性+同校(SubjectDirectory 策略接口,生产走 /oapi/user/byId)、 P34 附件 key 与 store_key 一致性比对;P21 FormAdminGuard 整体替换为"授权模块集"口径 (ModuleTreeSource 策略接口,与 scope 解析共用同一棵树)。 新增对外读接口 GET /oapi/form/schema(内联 options+valueSchema)与 /oapi/form/answer (钉住版本 schema+值文档);导出 easyexcel 流式同步响应,选项列显 label、附件列显 fileName。 自验:mvn -B -o clean package 177 条全绿(本轮新增 56,23 条打真实 MySQL), 覆盖 V9/V11/V23/V27/V28/V37/V42 + V30 读侧半边 + V39 范围半边; V9 以"发布时补建索引行"实现(方案未指定机制,记 P36)。详见 docs/T4-report.md(问题单 P35~P46)。
This commit is contained in:
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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 结论。)
|
||||
|
||||
—— 全文完 ——
|
||||
@@ -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` (
|
||||
|
||||
@@ -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 最后一句):<b>算子按字段 type 定,不给自由表达式</b>——
|
||||
* 等值(选项类)、区间(date/number)、前缀模糊(文本类)。
|
||||
*
|
||||
* <p>调用方只能提交 {@code {key,value}} 或 {@code {key,from,to}};本类按钉住<b>当前发布版本</b>的
|
||||
* 字段类型决定它落到 {@code answer_index} 的哪一列(v_str / v_num / v_date)、用哪种算子,
|
||||
* 形态不对就拒("给 text 传 from/to"、"给 date 传区间外的串"、"给 number 传非数字"都不是筛选语义,
|
||||
* 是查询注入的入口)。类型正确性在这里保证,V4 的"日期按日期比、不走字典序"也在这里保证。</p>
|
||||
*/
|
||||
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<String> OPTION_TYPES = new LinkedHashSet<>(Arrays.asList(
|
||||
"select", "radio", "checkbox", "multiselect"));
|
||||
private static final Set<String> TEXT_TYPES = new LinkedHashSet<>(Arrays.asList(
|
||||
"text", "textarea", "phone"));
|
||||
|
||||
private FormFilterOperators() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次筛选条件的解析结果(读路径构造器只认这个,不再回看 schema)。
|
||||
*
|
||||
* <p>数值/日期<b>恒为闭区间</b>:等值就是两端同值的区间,因此 {@code numFrom/numTo}
|
||||
* (或 {@code dateFrom/dateTo})总是成对非空——构造器只用一个 {@code between} 分支,
|
||||
* 不给"漏判一种算子"留余地。文本类才有 {@code strValue}(等值或前缀)。</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 权限锚点)。
|
||||
*
|
||||
* <p>模块行有两种身份,<b>必须分开取</b>:</p>
|
||||
* <ul>
|
||||
* <li>{@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);</li>
|
||||
* <li>{@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位,
|
||||
* 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。</p>
|
||||
*/
|
||||
public final class FormModuleKeys {
|
||||
|
||||
private FormModuleKeys() {
|
||||
}
|
||||
|
||||
/** 授权页面路由集合(path 空的权限锚点不在内) */
|
||||
public static Set<String> paths(List<BoaModule> tree) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collect(tree, true, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 授权 customId 集合(path 空的权限锚点主要靠它表达) */
|
||||
public static Set<String> customIds(List<BoaModule> tree) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collect(tree, false, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void collect(List<BoaModule> nodes, boolean wantPath, Set<String> out) {
|
||||
if (nodes == null) {
|
||||
return;
|
||||
}
|
||||
for (BoaModule node : nodes) {
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
String value = wantPath ? node.getPath() : node.getCustomId();
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
out.add(value.trim());
|
||||
}
|
||||
collect(node.getChildren(), wantPath, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,6 +382,30 @@ public final class FormSchemaValidator {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可筛选字段(key → 字段节点):{@link #searchableKeys} 的<b>筛选候选口径</b>,
|
||||
* 比索引写入少一类——{@code upload/image} 排除在外。
|
||||
*
|
||||
* <p>依据执行手册 §5 <b>P28 裁决</b>:契约 §2 只给三类算子(等值/区间/前缀模糊),附件没有算子,
|
||||
* 索引侧"不写行只 warn"(T3 已落)、筛选候选侧"不进候选"(本轮)。两处同一个理由,
|
||||
* 不能让前端拿到一个勾了 searchable 的附件字段、提交筛选后恒返回空集。</p>
|
||||
*/
|
||||
public static Map<String, JsonNode> filterableFields(String schemaJson) {
|
||||
Map<String, JsonNode> result = new LinkedHashMap<>();
|
||||
Map<String, JsonNode> fields = fieldsByKeys(schemaJson);
|
||||
for (String key : searchableKeys(schemaJson)) {
|
||||
JsonNode field = fields.get(key);
|
||||
if (field == null || NO_OPERATOR_TYPES.contains(field.path("type").asText())) {
|
||||
continue;
|
||||
}
|
||||
result.put(key, field);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 无检索算子的类型(P28):附件两类一期既不写索引行、也不进筛选候选 */
|
||||
private static final Set<String> NO_OPERATOR_TYPES = new LinkedHashSet<>(Arrays.asList("upload", "image"));
|
||||
|
||||
/** schema 里引用的字典 code({@code dict:{code}} 去掉了前缀),发布前据此查可解析性 */
|
||||
public static List<String> dictRefCodes(String schemaJson) {
|
||||
List<String> codes = new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.formcenter.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* 机读值形状生成器(契约 §4:schema 读接口每个字段附 {@code valueSchema},draft-07 风格片段)。
|
||||
*
|
||||
* <p>生成依据是契约 §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。</p>
|
||||
*
|
||||
* <p><b>不做的事</b>:不发明契约里没有的约束(不给 number 造 {@code multipleOf}、
|
||||
* 不给多选加 {@code uniqueItems});一期未放行的类型(address/user/users)不给形状——
|
||||
* 它们在发布校验就被拦下,出现在这里只会误导消费方按二期语义接线。</p>
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,14 +92,19 @@ public final class FormValueValidator {
|
||||
return valuesJson;
|
||||
}
|
||||
|
||||
/** 提交值里引用的附件 id(服务层据此做同校归属校验,防把别人的 key 塞进自己的答卷) */
|
||||
public static List<Long> attachmentFileIds(String valuesJson, String schemaJson) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
/**
|
||||
* 值文档里引用的附件对象(upload/image 字段数组的每个 {@code {id,fileName,key}} 元素)。
|
||||
*
|
||||
* <p>返回整对象而不只返回 id:服务层的归属校验除"id 必须存在且属本校"外,还要比对
|
||||
* {@code key==form_file.store_key}(P34 裁决)——只给 id 就得把值文档再走一遍。</p>
|
||||
*/
|
||||
public static List<JsonNode> attachments(String valuesJson, String schemaJson) {
|
||||
List<JsonNode> files = new ArrayList<>();
|
||||
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
|
||||
JsonNode data = FormSchemaValidator.parse(
|
||||
valuesJson == null || valuesJson.trim().isEmpty() ? "{}" : valuesJson, "表单数据不是合法 JSON 对象");
|
||||
if (!data.isObject()) {
|
||||
return ids;
|
||||
return files;
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
|
||||
if (!ATTACHMENT_TYPES.contains(entry.getValue().path("type").asText())) {
|
||||
@@ -110,13 +115,12 @@ public final class FormValueValidator {
|
||||
continue;
|
||||
}
|
||||
for (JsonNode item : value) {
|
||||
JsonNode id = item.get("id");
|
||||
if (id != null && id.isIntegralNumber()) {
|
||||
ids.add(id.longValue());
|
||||
if (item != null && item.isObject() && item.get("id") != null && item.get("id").isIntegralNumber()) {
|
||||
files.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
return files;
|
||||
}
|
||||
|
||||
// ==================== 分派 ====================
|
||||
|
||||
@@ -44,21 +44,21 @@ public class FormAdminController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam(required = false) String appCode,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
return defService.list(adminGuard.requireAdmin(user, schId), appCode, status);
|
||||
return defService.list(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), appCode, status);
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
public FormModels.FormView get(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.get(adminGuard.requireAdmin(user, schId), uuid);
|
||||
return defService.get(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
}
|
||||
|
||||
@GetMapping("/versions")
|
||||
public List<FormModels.VersionView> versions(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.versions(adminGuard.requireAdmin(user, schId), uuid);
|
||||
return defService.versions(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
}
|
||||
|
||||
/** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */
|
||||
@@ -67,27 +67,28 @@ public class FormAdminController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid,
|
||||
@RequestParam("version") Integer version) {
|
||||
return defService.version(adminGuard.requireAdmin(user, schId), uuid, version);
|
||||
return defService.version(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选候选列(V3 的接口侧断言点):<b>只有显式声明 searchable 的字段</b>才进候选,
|
||||
* 取当前发布版本的 schema;未发布时返回空列表(草稿字段不参与筛选)。
|
||||
* 附件类(upload/image)即便勾了 searchable 也<b>排除</b>——契约 §2 没给它检索算子(P28)。
|
||||
*/
|
||||
@GetMapping("/filterFields")
|
||||
public List<String> filterFields(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId), uuid);
|
||||
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
return current == null ? java.util.Collections.emptyList()
|
||||
: FormSchemaValidator.searchableKeys(current.getSchemaJson());
|
||||
: new java.util.ArrayList<>(FormSchemaValidator.filterableFields(current.getSchemaJson()).keySet());
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
public FormModels.FormView create(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormCreateRequest body) {
|
||||
return defService.create(adminGuard.requireAdmin(user, schId), body);
|
||||
return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
}
|
||||
|
||||
/** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */
|
||||
@@ -95,7 +96,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView save(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormSaveRequest body) {
|
||||
return defService.save(adminGuard.requireAdmin(user, schId), body);
|
||||
return defService.save(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
}
|
||||
|
||||
/** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */
|
||||
@@ -104,7 +105,7 @@ public class FormAdminController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid,
|
||||
@RequestParam(value = "changeLog", required = false) String changeLog) {
|
||||
return defService.publish(adminGuard.requireAdmin(user, schId), uuid, changeLog);
|
||||
return defService.publish(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, changeLog);
|
||||
}
|
||||
|
||||
/** 停用:只断填报入口,数据可读可导(方案 §5.4) */
|
||||
@@ -112,7 +113,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView disable(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.disable(adminGuard.requireAdmin(user, schId), uuid);
|
||||
return defService.disable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
}
|
||||
|
||||
/** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */
|
||||
@@ -120,7 +121,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView enable(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.enable(adminGuard.requireAdmin(user, schId), uuid);
|
||||
return defService.enable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
}
|
||||
|
||||
/** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */
|
||||
@@ -128,7 +129,7 @@ public class FormAdminController {
|
||||
public void softDelete(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
defService.softDelete(adminGuard.requireAdmin(user, schId), uuid);
|
||||
defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
}
|
||||
|
||||
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行 */
|
||||
@@ -136,7 +137,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView copy(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormCopyRequest body) {
|
||||
return defService.copy(adminGuard.requireAdmin(user, schId), body);
|
||||
return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
}
|
||||
|
||||
/** 表单级配置:面向人群/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签 */
|
||||
@@ -144,7 +145,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView config(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormConfigRequest body) {
|
||||
return defService.updateConfig(adminGuard.requireAdmin(user, schId), body);
|
||||
return defService.updateConfig(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
}
|
||||
|
||||
/** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */
|
||||
@@ -152,7 +153,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView statusDefs(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.StatusDefsRequest body) {
|
||||
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId), body);
|
||||
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
}
|
||||
|
||||
// ==================== 选项字典(契约 §2.1) ====================
|
||||
@@ -160,36 +161,36 @@ public class FormAdminController {
|
||||
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
|
||||
@GetMapping("/dict/list")
|
||||
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return dictService.listOwn(adminGuard.requireAdmin(user, schId));
|
||||
return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
|
||||
}
|
||||
|
||||
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
|
||||
@GetMapping("/dict/selectable")
|
||||
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return dictService.listSelectable(adminGuard.requireAdmin(user, schId));
|
||||
return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
|
||||
}
|
||||
|
||||
@PostMapping("/dict/save")
|
||||
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
|
||||
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId()), body.getCode(), body.getName());
|
||||
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode(), body.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/dict/delete")
|
||||
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
|
||||
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId()), body.getCode());
|
||||
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode());
|
||||
}
|
||||
|
||||
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
|
||||
@PostMapping("/dict/item/save")
|
||||
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
|
||||
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId()),
|
||||
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT),
|
||||
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
|
||||
}
|
||||
|
||||
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
|
||||
@PostMapping("/dict/item/delete")
|
||||
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
|
||||
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId()), body.getDictCode(), body.getValue());
|
||||
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getDictCode(), body.getValue());
|
||||
}
|
||||
|
||||
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
|
||||
|
||||
@@ -17,11 +17,14 @@ import java.util.List;
|
||||
* 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。
|
||||
*
|
||||
* <p>这里<b>没有</b>编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用"
|
||||
* 的实现形态就是<b>不存在这些端点</b>,加上管理权守卫给出的 403)。改状态同理:填表人在接口层即被拒(V14)。</p>
|
||||
*
|
||||
* <p>也不提供列表/详情读接口:那四条读出路径(列表/详情/统计/导出)必须共用同一个 scope 构造器
|
||||
* (方案 §6.4 红线,V27/V30),归 T4 一次建成,避免本轮先写一个没有范围过滤的读接口、T4 再补一遍。</p>
|
||||
* 的实现形态就是<b>不存在这些端点</b>,加上数据管理模块行守卫给出的 403)。改状态同理:
|
||||
* 填表人在接口层即被拒(V14)。</p>
|
||||
*
|
||||
* <p><b>两层权限</b>(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data},
|
||||
* 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次<b>行级数据范围</b>
|
||||
* (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动,
|
||||
* 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。</p>
|
||||
|
||||
* <p>{@code schId} 参数只是超管跨校的目标域(校级传别的值即拒,同 {@link FormAdminController} 口径);
|
||||
* 答卷定位按 {@code (域, uuid)},跨校答卷查不到。</p>
|
||||
*/
|
||||
@@ -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<AnswerModels.BatchItemView> 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);
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
return adminService.batchSoftDelete(user, domain, body);
|
||||
}
|
||||
|
||||
/** 批量改状态:同上 */
|
||||
@@ -74,7 +77,7 @@ public class FormAnswerAdminController {
|
||||
public List<AnswerModels.BatchItemView> 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);
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
return adminService.batchChangeStatus(user, domain, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.yunhai.formcenter.controller.api;
|
||||
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.service.AnswerExportService;
|
||||
import com.yunhai.formcenter.service.AnswerReadService;
|
||||
import com.yunhai.formcenter.service.AnswerStatsService;
|
||||
import com.yunhai.formcenter.service.DataScopeService;
|
||||
import com.yunhai.formcenter.service.FormAdminGuard;
|
||||
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.web.bind.annotation.GetMapping;
|
||||
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 javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。
|
||||
*
|
||||
* <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p>
|
||||
* <ul>
|
||||
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data},
|
||||
* 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");</li>
|
||||
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId,
|
||||
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校——
|
||||
* 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。</p>
|
||||
*
|
||||
* <p>身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径);
|
||||
* 响应只出现 uuid(V5)。列表是两段式的对外形态、导出是流式 xlsx(不走统一 JSON 信封,
|
||||
* 见 {@link #export} 注释)。</p>
|
||||
*/
|
||||
@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<ReadModels.FilterFieldView> filterFields(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("formUuid") String formUuid) {
|
||||
requireFormUuid(formUuid);
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
return readService.filterFields(domain, formUuid);
|
||||
}
|
||||
|
||||
/** 统计(总数/按状态/按字段分组,前端不聚合;受范围约束 + 仅升版失效的短 TTL 缓存) */
|
||||
@PostMapping("/stats")
|
||||
public ReadModels.StatsView stats(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody ReadModels.QueryRequest body) {
|
||||
requireFormUuid(body.getFormUuid());
|
||||
Long domain = adminGuard.requireAdminAny(user, schId,
|
||||
FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
return statsService.stats(user, domain, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出(xlsx 流式同步响应)。<b>不套 JSON 信封</b>:这是文件流,{@code ApiResponseAdvice} 对
|
||||
* 已提交的双进制响应不介入(返回类型 void、直接写 OutputStream),失败时仍由全局异常处理回到信封。
|
||||
*
|
||||
* <p>响应头里文件名只用 code(URL-safe 子集,P16 定稿)拼,避免中文文件名在各浏览器上的编码分支;
|
||||
* 行数=该身份范围下的总数(V37),受范围约束由 {@code AnswerExportService} 与读路径同源保证。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <pre>
|
||||
* GET /oapi/form/schema?schId=&code=[&appCode=] → data=内联 options + valueSchema 的机读 schema(契约 §4)
|
||||
* GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9)
|
||||
* </pre>
|
||||
*
|
||||
* <p>信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调
|
||||
* (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。</p>
|
||||
*
|
||||
* <p>{@code appCode} 是 {@code /schema} 的<b>可选</b>参数(契约 §9 只写 schId+code):
|
||||
* 不传=form-center(中心自己的表单),传=取该快应用归属下的同 code 表单。
|
||||
* 加它是为了让快应用表单也能被对外读到,且不改变缺省行为——登记执行手册 §5 问题单。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,10 @@ import javax.persistence.UniqueConstraint;
|
||||
*
|
||||
* <p>三条钉死的事实:</p>
|
||||
* <ul>
|
||||
* <li><b>版本钉住</b>:{@code (sch_id, app_code, code, version)} 是提交时解析到的版本冗余下来
|
||||
* (方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
|
||||
* 在途提交天然按旧版落地,编辑/详情/导出也都按这一行列语义走。</li>
|
||||
* <li><b>版本钉住</b>:{@code form_uuid} + {@code version} 是提交时解析到的版本坐标
|
||||
* (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与
|
||||
* 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
|
||||
* 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。</li>
|
||||
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、
|
||||
* {@code sch_id}=会话校,<b>全部会话推导不可外部传入</b>(契约 §5、V17)。</li>
|
||||
* <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1),
|
||||
@@ -37,7 +38,12 @@ import javax.persistence.UniqueConstraint;
|
||||
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")
|
||||
@Index(name = "idx_answer_subject", columnList = "subject_id"),
|
||||
// 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id)
|
||||
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
|
||||
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
|
||||
// 不含 app_code 且无法在跨状态时给出 id 序 → 新增本索引,登记问题单。
|
||||
@Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id")
|
||||
})
|
||||
public class Answer extends BaseEntity {
|
||||
|
||||
@@ -47,6 +53,18 @@ public class Answer extends BaseEntity {
|
||||
@Column(name = "uuid", nullable = false, length = 32, updatable = false)
|
||||
private String uuid;
|
||||
|
||||
/**
|
||||
* 提交时解析<b>命中</b>的那张表单的 uuid(P25 裁决:钉版溯源的精确坐标)。
|
||||
*
|
||||
* <p>为什么不能只靠 {@code (sch_id, app_code, code, version)}:那组坐标不含"表单归属校",
|
||||
* 平台级表单(sch_id=0)被本校后建的同 {@code (app_code, code)} 表单遮蔽后就出现歧义窗口——
|
||||
* 遮蔽规则会把解析指向本校表单,版本号若恰好对得上,历史答卷会被<b>静默按错 schema</b>
|
||||
* 校验/重建索引(比查不到更糟)。按 form_uuid → form(uk_form_uuid) → 版本行取数没有这个窗口:
|
||||
* uuid 跨版本不变(契约 §1),遮蔽与升版都不影响它指向的表单。</p>
|
||||
*/
|
||||
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
|
||||
private String formUuid;
|
||||
|
||||
@Column(name = "sch_id", nullable = false, updatable = false)
|
||||
private Long schId;
|
||||
|
||||
|
||||
@@ -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):<b>闭集四枚举,一期契约即含全部四值</b>——
|
||||
* 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。
|
||||
*
|
||||
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
|
||||
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yunhai.formcenter.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。
|
||||
*
|
||||
* <p>信封 {@code {code,msg,data}} 与 {@code code=0} 由平台 starter 的统一响应链负责(同 flow-center
|
||||
* {@code /oapi/flow/**} 先例),本层只交 data 载荷;因此这里<b>不出现</b>任何"未找到"专用码——
|
||||
* 未找到按契约 §6 定稿映射走 {@code PARAM_ERROR(2)} + msg。</p>
|
||||
*
|
||||
* <p>不外露的三样:库内 {@code id}(契约 §1/V5)、字典本身({@code options} 一律内联展开,契约 §2.1)、
|
||||
* 附件绝对 URL(只有值里的 {@code {id,fileName,key}},V24)。</p>
|
||||
*/
|
||||
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 端点下发的是<b>当前发布版本</b>,答卷钉的可能是历史版本——
|
||||
* 只给引用不给内容,消费方拿不到"当时那张表",快照语义就落空(契约 §9"快照请消费方值拷贝")。
|
||||
*/
|
||||
private JsonNode groups;
|
||||
}
|
||||
|
||||
/** schema 坐标(不含库内 id) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class SchemaRef {
|
||||
private String formUuid;
|
||||
private String appCode;
|
||||
private String code;
|
||||
private Integer version;
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>三条对外纪律:</p>
|
||||
* <ul>
|
||||
* <li><b>游标也是 uuid</b>:keyset 的游标值对外是 {@code cursor}=上一页末行答卷的 uuid,
|
||||
* 服务端按 {@code uk_answer_uuid} 换成库内 id 再比——自增 id 不出现在任何响应外层(契约 §1/V5)。
|
||||
* 代价是每页多一次索引点查,比把 id 泄露进 URL 便宜得多;</li>
|
||||
* <li><b>排序列不外露</b>:读请求没有"按某列排序"的参数,一期恒为提交时间序(方案 §5.5 铁律②),
|
||||
* 只有方向;</li>
|
||||
* <li><b>值文档原样回传</b>:三态空值(无值/无 key/已删字段)的降级显示由前端做(契约 §3),
|
||||
* 后端不改写、不补占位。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class ReadModels {
|
||||
|
||||
private ReadModels() {
|
||||
}
|
||||
|
||||
/** 列表/统计/导出共用的查询条件(filters 的算子由服务端按字段类型定,见 {@link FilterItem}) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class QueryRequest {
|
||||
/** 目标表单 uuid(数据面身份,契约 §1) */
|
||||
private String formUuid;
|
||||
/** 状态 code 精确过滤,可空 */
|
||||
private String status;
|
||||
/** searchable 字段筛选(候选集见 {@link FilterFieldView}) */
|
||||
private List<FilterItem> filters;
|
||||
/** 仅统计接口消费:按哪个 searchable 字段分组,可空=只出总数与按状态 */
|
||||
private String groupByField;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个筛选条件。<b>算子不是调用方给的</b>:契约 §2 只给三类算子,按字段 type 收敛——
|
||||
* 等值(select/radio/checkbox/multiselect)、区间(date/number,用 {@code from}/{@code to})、
|
||||
* 前缀模糊(text/textarea/phone,用 {@code value})。给错形态按 PARAM_ERROR 拒。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class FilterItem {
|
||||
private String key;
|
||||
/** 等值/前缀值 */
|
||||
private String value;
|
||||
/** 区间下界(含):number 数值串、date 的 yyyy-MM-dd */
|
||||
private String from;
|
||||
/** 区间上界(含) */
|
||||
private String to;
|
||||
}
|
||||
|
||||
/** 列表请求(keyset 游标 + 方向) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ListRequest extends QueryRequest {
|
||||
/** 上一页末行的 answerId(uuid);首屏不传 */
|
||||
private String cursor;
|
||||
/** 方向:desc(默认,最新在前)/asc;排序列恒为提交时间 */
|
||||
private String order;
|
||||
/** 页大小,默认 20、上限 100(方案 §5.5:当页 10~20 时 JSON 解析成本可忽略) */
|
||||
private Integer size;
|
||||
}
|
||||
|
||||
/** 列表结果:rows + 下一页游标;没有 offset/total 页码概念(铁律②禁裸 LIMIT offset) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ListView {
|
||||
private List<RowView> rows = new ArrayList<>();
|
||||
/** 传给下一页请求的 cursor;当页为空即 null(到底) */
|
||||
private String nextCursor;
|
||||
private Integer size;
|
||||
/** 生效范围(枚举名,便于前端与验收断言对照;不承诺 UI 文案) */
|
||||
private Set<DataScope> scopes;
|
||||
}
|
||||
|
||||
/** 列表行(值文档原样,列由前端按 schema 展) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class RowView {
|
||||
private String answerId;
|
||||
private String formUuid;
|
||||
private Integer version;
|
||||
private String status;
|
||||
/** 状态 label;已废弃 code 由前端标"(已移除)",这里给 null 即表示定义里没有该 code */
|
||||
private String statusLabel;
|
||||
private Integer submitterId;
|
||||
private Integer subjectId;
|
||||
private String answerNo;
|
||||
/** 提交时间 yyyy-MM-dd HH:mm:ss(= create_time,一期唯一排序键) */
|
||||
private String submitTime;
|
||||
private JsonNode values;
|
||||
}
|
||||
|
||||
/** 详情:行 + 钉住版本的 schema(内联展开后的 groups),消费方/前端按它渲染历史数据(契约 §9 同形) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class DetailView extends RowView {
|
||||
private String appCode;
|
||||
private String code;
|
||||
private String formName;
|
||||
/** 钉住版本(该答卷自己的 schema,不随升版改变) */
|
||||
private JsonNode groups;
|
||||
}
|
||||
|
||||
/** 统计结果(V11:总数/按状态/按字段分组,前端不做聚合) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class StatsView {
|
||||
private long total;
|
||||
private List<CountItem> byStatus = new ArrayList<>();
|
||||
/** 按字段分组结果;未请求分组时为空列表 */
|
||||
private List<FieldGroups> byField = new ArrayList<>();
|
||||
/** true=本结果来自统计缓存(短 TTL,容忍 N 分钟延迟,方案 §5.5 铁律④) */
|
||||
private Boolean cached;
|
||||
}
|
||||
|
||||
/** 一个分组桶:value=分组键(状态 code / 索引值),label=展示名(可空),count=条数 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CountItem {
|
||||
private String value;
|
||||
private String label;
|
||||
private long count;
|
||||
|
||||
public CountItem() {
|
||||
}
|
||||
|
||||
public CountItem(String value, String label, long count) {
|
||||
this.value = value;
|
||||
this.label = label;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按字段分组的结果集(一个字段一组桶) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class FieldGroups {
|
||||
private String key;
|
||||
private String label;
|
||||
private List<CountItem> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 筛选候选列(V3/P28:只有 searchable 且<em>有算子</em>的字段才出现在这里) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class FilterFieldView {
|
||||
private String key;
|
||||
private String label;
|
||||
private String type;
|
||||
/** EQ(等值)/ RANGE(区间)/ PREFIX(前缀模糊)——由 type 推导,前端按它出控件 */
|
||||
private String operator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读路径内部查询规格(服务端推导,<b>不从请求体绑定</b>):租户域 + 表单坐标 + 范围 + 筛选 + 游标。
|
||||
* {@link AnswerScopeQueryBuilder} 的入参,四条读出路径与管理动作行级把关共用同一个形状。
|
||||
*
|
||||
* <p>{@code filters} 装的是已按字段类型收敛过的 {@link FormFilterOperators.Spec}——
|
||||
* 请求里的裸条件(字符串三元组)在服务层解析成 Spec,构造器因此不认识 schema、也不给注入面。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Query {
|
||||
private Long schId;
|
||||
private String appCode;
|
||||
private String code;
|
||||
private Set<DataScope> scopes;
|
||||
private Integer userId;
|
||||
private String status;
|
||||
private List<FormFilterOperators.Spec> filters;
|
||||
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
|
||||
private Long cursorId;
|
||||
private boolean ascending;
|
||||
private int size = 20;
|
||||
/** 行级 uuid 限定(详情/管理动作的"这条在不在范围内"判定用),可空 */
|
||||
private String answerUuid;
|
||||
|
||||
public List<FormFilterOperators.Spec> filtersOrEmpty() {
|
||||
return filters == null ? new ArrayList<>() : filters;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ public interface AnswerIndexRepository extends JpaRepository<AnswerIndex, Long>
|
||||
|
||||
List<AnswerIndex> findByAnswerIdOrderByIdAsc(Long answerId);
|
||||
|
||||
/** 该答卷已有索引行的字段(升版补建去重用,方案 §5.2 索引行生命周期) */
|
||||
@Query("select x.fieldKey from AnswerIndex x where x.answerId = :answerId")
|
||||
List<String> findFieldKeysByAnswerId(@Param("answerId") Long answerId);
|
||||
|
||||
/** 重建/软删清理:bulk delete(方案 §7.7 全量覆盖式保存用 @Modifying) */
|
||||
@Modifying
|
||||
@Query("delete from AnswerIndex x where x.answerId = :answerId")
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.yunhai.formcenter.repository;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 答卷仓库。数据面一律按 {@code uuid + sch_id} 定位(契约 §1:id 绝不外露、uuid 不可枚举)。
|
||||
* 检索/统计/导出的读路径在 T4(四读出路径共用同一 scope 构造器,方案 §6.4 红线),
|
||||
* 本轮只放提交与管理操作必需的查询——<b>不提供任何物理删除方法</b>(P22/V30)。
|
||||
* T4 的检索/统计/导出不在这里写查询——那四条读出路径必须共用同一个 scope 构造器
|
||||
* (方案 §6.4 红线),实现落在 {@code AnswerScopeQueryBuilder},本仓库只提供它取不到形状的
|
||||
* 两段式第二段({@link #findLiveByIds})与提交/管理操作的定位方法。
|
||||
* <b>answer 数据不提供任何物理删除方法</b>(P22/V30;{@code FillQuotaRepository.releaseQuota}
|
||||
* 的删除是"释放名额",方案 §5.4 点名行为,不属答卷数据删除)。
|
||||
*/
|
||||
public interface AnswerRepository extends JpaRepository<Answer, Long> {
|
||||
|
||||
@@ -20,6 +25,16 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
|
||||
|
||||
Optional<Answer> findByUuidAndSchId(String uuid, Long schId);
|
||||
|
||||
/**
|
||||
* 两段式列表的第二段(方案 §5.5 铁律①):<b>按当页 id 批量取整文档</b>。
|
||||
* 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json};
|
||||
* 这里才取 MEDIUMTEXT——一页 10~20 行,JSON 解析成本可忽略。
|
||||
* 仍带 {@code sch_id} 与 {@code is_deleted=0}:拿到 id 列表后不再放宽条件,
|
||||
* 免得"范围判定"与"取数"两处口径分叉。
|
||||
*/
|
||||
@Query("select a from Answer a where a.schId = :schId and a.isDeleted = 0 and a.id in (:ids)")
|
||||
List<Answer> findLiveByIds(@Param("schId") Long schId, @Param("ids") Collection<Long> ids);
|
||||
|
||||
/**
|
||||
* 报名号当日序号:统计该 (校, app_code, code) 下以 {@code {code}-{YYYYMMDD}-} 开头的行数(<b>含软删行</b>——
|
||||
* 软删不回收号,否则同日重填会出现两个相同报名号)。前缀由服务端拼接,参数绑定进 JPQL。
|
||||
@@ -31,4 +46,15 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
|
||||
|
||||
/** 测试与审计取证:某表单下的全部答卷(按 id 升序) */
|
||||
List<Answer> findBySchIdAndAppCodeAndCodeOrderByIdAsc(Long schId, String appCode, String code);
|
||||
|
||||
/**
|
||||
* 升版补建索引行的分批扫描({@code AnswerIndexService#backfillAfterPublish}):
|
||||
* 按 {@code form_uuid}(P25 的精确坐标)取未删答卷、id 游标推进、批大小由 Pageable 下推。
|
||||
* 这是<b>唯一</b>允许整页取 {@code values_json} 的非读接口路径——补建要读的就是值文档本身,
|
||||
* 且触发点是低频的发布动作,不是列表/统计/导出(那三条的铁律①约束在 {@code AnswerScopeQueryBuilder})。
|
||||
*/
|
||||
@Query("select a from Answer a where a.formUuid = :formUuid and a.isDeleted = 0 and a.id > :cursor "
|
||||
+ "order by a.id asc")
|
||||
List<Answer> findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ public interface FormRepository extends JpaRepository<Form, Long> {
|
||||
|
||||
Optional<Form> findByUuidAndSchId(String uuid, Long schId);
|
||||
|
||||
/**
|
||||
* 按 uuid 精确取头表(P25:数据面 {@code answer.form_uuid} 的溯源入口,uk_form_uuid 唯一)。
|
||||
* <b>不带 sch_id 条件、也不过滤 is_deleted</b>——答卷的钉版坐标是全局 uuid,
|
||||
* 归属校由 {@code answer.sch_id} 自己保证;软删表单的历史答卷仍可溯源(P32/方案 §5.4)。
|
||||
* 租户域复核在调用方({@code FormDefService#readDomainForm})。
|
||||
*/
|
||||
Optional<Form> findByUuid(String uuid);
|
||||
|
||||
/**
|
||||
* 管理端列表:本校未删表单(软删行不进列表,方案 §5.4)。
|
||||
* app_code/status 过滤在应用层做——一所学校的表单量级是个位数到几百,
|
||||
@@ -35,14 +43,4 @@ public interface FormRepository extends JpaRepository<Form, Long> {
|
||||
List<Form> findResolveCandidates(@Param("code") String code,
|
||||
@Param("appCode") String appCode,
|
||||
@Param("schIds") Collection<Long> schIds);
|
||||
|
||||
/**
|
||||
* 数据面取域(T3 答卷溯源):同一条遮蔽规则,但<b>不排除软删表单</b>——
|
||||
* 表单只有软删、没有物理删除(方案 §5.4),其历史答卷仍要可按钉住版本校验与溯源,
|
||||
* 否则"删掉表单"会连带把存量数据变成无法管理的孤儿。
|
||||
*/
|
||||
@Query("select f from Form f where f.code = :code and f.appCode = :appCode and f.schId in (:schIds)")
|
||||
List<Form> findDataDomainCandidates(@Param("code") String code,
|
||||
@Param("appCode") String appCode,
|
||||
@Param("schIds") Collection<Long> schIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
/**
|
||||
* 范围实现器:<b>关于我</b>({@code subject_id}=会话用户)。
|
||||
*
|
||||
* <p>与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是
|
||||
* <em>关于自己</em>的数据,<b>包含老师代填的那一条</b>——老师代填时 {@code submitter_id} 是老师、
|
||||
* {@code subject_id} 是学生,按本范围才可见(V18)。</p>
|
||||
*/
|
||||
@Component
|
||||
public class AboutMeScopeImplementor implements AnswerScopeImplementor {
|
||||
|
||||
@Override
|
||||
public DataScope scope() {
|
||||
return DataScope.ABOUT_ME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean selectable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
return cb.equal(root.get("subjectId"), userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */
|
||||
@Component
|
||||
public class AllScopeImplementor implements AnswerScopeImplementor {
|
||||
|
||||
@Override
|
||||
public DataScope scope() {
|
||||
return DataScope.ALL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean selectable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
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.AnswerOpLog;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.entity.FormFile;
|
||||
import com.yunhai.formcenter.entity.StatusLog;
|
||||
import com.yunhai.formcenter.model.AnswerModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
|
||||
import com.yunhai.formcenter.repository.AnswerRepository;
|
||||
import com.yunhai.formcenter.repository.FillQuotaRepository;
|
||||
@@ -15,6 +18,7 @@ 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 com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -24,24 +28,26 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 答卷管理操作(契约 §5/§10,方案 §5.4 生命周期表"谁碰、怎么碰"那一列的落地)。
|
||||
* 答卷管理操作(契约 §5/§8/§10,方案 §5.4 生命周期表"谁碰、怎么碰"那一列的落地)。
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>编辑</b>:仅管理员,原地修正值文档;<b>按该答卷提交时钉住的 version 校验与渲染</b>
|
||||
* (V8 同族——新 schema 的字段不会凭空出现在旧数据上,旧 schema 已删的字段也不会被新校验误拒);
|
||||
* 写 {@code answer_op_log},索引同步重建。不改状态、不改身份、不看截止时间。</li>
|
||||
* <li><b>软删</b>:唯一删除形态(<b>无物理删除接口</b>,V30/P22);同步删 {@code answer_index} 行
|
||||
* (方案 §5.2)+ <b>释放 fill_quota 名额</b>(管理员删错单后学生可重填,方案 §5.4 点名场景);
|
||||
* 留痕表不动——痕迹是审计资产。</li>
|
||||
* <li><b>编辑</b>:仅管理员,原地修正值文档;<b>按该答卷钉住的版本校验与渲染</b>
|
||||
* (P25:坐标={@code answer.form_uuid + version},V8 同族——新 schema 的字段不会凭空出现在旧数据上,
|
||||
* 旧 schema 已删的字段也不会被新校验误拒);写 {@code answer_op_log},索引同步重建。
|
||||
* 不改状态、不改身份、不看截止时间(P32)。</li>
|
||||
* <li><b>软删</b>:唯一删除形态(<b>无物理删除接口</b>,V30);同步删 {@code answer_index} 行(方案 §5.2)
|
||||
* + <b>释放 fill_quota 名额</b>(方案 §5.4 点名场景);留痕表不动——痕迹是审计资产。</li>
|
||||
* <li><b>改状态</b>:只写 {@code status_log},<b>系统内不存在任何转移规则校验</b>(V15);
|
||||
* 填表人在接口层即被拒(V14)。目标状态必须是该表单状态定义里的<b>未废弃</b> code。</li>
|
||||
* <li><b>批量</b>:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务,
|
||||
* 一条失败不回滚其它条,结果逐条返回。这是"批量与单条完全一致"的实现形态。</li>
|
||||
* <li><b>批量</b>:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务,一条失败不回滚其它条。
|
||||
* <b>批量同样受数据范围约束</b>(V39 的范围半边):范围检查在每条的执行体里,
|
||||
* 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>权限:调用方(控制器)已用 {@code FormAdminGuard} 拿到管理权与租户域,本服务只按
|
||||
* {@code (schId, answerUuid)} 定位——<b>跨校答卷根本查不到</b>,越权面在定位这一步就关死。
|
||||
* 数据范围(契约 §8 矩阵/scope 构造器)属 T4,本轮不做行级过滤。</p>
|
||||
* <p><b>两层权限都过</b>(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard},
|
||||
* 决定"能不能用数据管理接口"),每条答卷再过一次<b>行级范围</b>——判据是四条读出路径共用的
|
||||
* {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此<b>列表里看不见的答卷,
|
||||
* 编辑/软删/改状态也做不了</b>:教师即便被授权了数据管理模块行,也只能动自己范围内的行。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -59,39 +65,42 @@ public class AnswerAdminService {
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final AnswerIndexService indexService;
|
||||
private final SubjectDirectory subjectDirectory;
|
||||
private final AnswerReadService readService;
|
||||
private final AnswerScopeQueryBuilder queryBuilder;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
// ==================== 单条 ====================
|
||||
|
||||
/** 管理员编辑:按钉住版本校验值文档,原地修正 + 重建索引 + 留痕(V29) */
|
||||
public void edit(Long schId, Integer operatorId, AnswerModels.EditRequest req) {
|
||||
transactionTemplate.executeWithoutResult(tx -> doEdit(schId, operatorId, req));
|
||||
/** 管理员编辑:行级范围 + 按钉住版本校验值文档 + 代填身份复核,原地修正、重建索引、留痕(V29/P30/P33/P34) */
|
||||
public void edit(BoaUser operator, Long schId, AnswerModels.EditRequest req) {
|
||||
transactionTemplate.executeWithoutResult(tx -> doEdit(operator, schId, req));
|
||||
}
|
||||
|
||||
/** 软删:唯一删除形态;同步删索引行、释放限次名额、留痕(V30/V32 后半) */
|
||||
public void softDelete(Long schId, Integer operatorId, String answerUuid) {
|
||||
transactionTemplate.executeWithoutResult(tx -> doSoftDelete(schId, operatorId, answerUuid, false));
|
||||
/** 软删:唯一删除形态;同步删索引行、释放限次名额、留痕(V30/V32 后半),受行级范围约束(P30) */
|
||||
public void softDelete(BoaUser operator, Long schId, String answerUuid) {
|
||||
transactionTemplate.executeWithoutResult(tx -> doSoftDelete(operator, schId, answerUuid, false));
|
||||
}
|
||||
|
||||
/** 改状态:非填表人(接口层由守卫把关,V14);写 status_log,无转移校验(V15) */
|
||||
public void changeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req) {
|
||||
transactionTemplate.executeWithoutResult(tx -> doChangeStatus(schId, operatorId, req, false));
|
||||
/** 改状态:非填表人(接口层由守卫把关,V14);写 status_log,无转移校验(V15),受行级范围约束 */
|
||||
public void changeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req) {
|
||||
transactionTemplate.executeWithoutResult(tx -> doChangeStatus(operator, schId, req, false));
|
||||
}
|
||||
|
||||
// ==================== 批量(逐条独立事务) ====================
|
||||
|
||||
public List<AnswerModels.BatchItemView> batchSoftDelete(Long schId, Integer operatorId,
|
||||
public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser operator, Long schId,
|
||||
AnswerModels.BatchRequest req) {
|
||||
List<String> ids = requireBatchIds(req.getAnswerIds());
|
||||
List<AnswerModels.BatchItemView> results = new ArrayList<>();
|
||||
for (String answerUuid : ids) {
|
||||
results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult(
|
||||
tx -> doSoftDelete(schId, operatorId, answerUuid, true))));
|
||||
tx -> doSoftDelete(operator, schId, answerUuid, true))));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<AnswerModels.BatchItemView> batchChangeStatus(Long schId, Integer operatorId,
|
||||
public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser operator, Long schId,
|
||||
AnswerModels.BatchRequest req) {
|
||||
List<String> ids = requireBatchIds(req.getAnswerIds());
|
||||
List<AnswerModels.BatchItemView> results = new ArrayList<>();
|
||||
@@ -100,19 +109,22 @@ public class AnswerAdminService {
|
||||
one.setAnswerId(answerUuid);
|
||||
one.setStatus(req.getStatus());
|
||||
results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult(
|
||||
tx -> doChangeStatus(schId, operatorId, one, true))));
|
||||
tx -> doChangeStatus(operator, schId, 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());
|
||||
private void doEdit(BoaUser operator, Long schId, AnswerModels.EditRequest req) {
|
||||
Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId());
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
String schemaJson = pinned.versionRow().getSchemaJson();
|
||||
|
||||
// P33:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校——
|
||||
// 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。
|
||||
assertSubjectStillValid(answer);
|
||||
|
||||
if (req.getValues() != null && !req.getValues().isNull() && !req.getValues().isObject()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "values 必须是 JSON 对象 {key:value}");
|
||||
}
|
||||
@@ -125,36 +137,34 @@ public class AnswerAdminService {
|
||||
answerRepository.save(answer);
|
||||
// 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变
|
||||
indexService.rebuild(answer, schemaJson);
|
||||
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operatorId,
|
||||
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(),
|
||||
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);
|
||||
private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) {
|
||||
Answer answer = requireManageableAnswer(operator, 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();
|
||||
Long formId = defService.pinnedForData(answer.getFormUuid(), 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 ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
|
||||
operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
|
||||
}
|
||||
|
||||
private void doChangeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req,
|
||||
private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req,
|
||||
boolean batched) {
|
||||
Answer answer = requireLiveAnswer(schId, req.getAnswerId());
|
||||
Answer answer = requireManageableAnswer(operator, 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());
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
Form form = pinned.form();
|
||||
// 目标状态必须是该表单定义里的未废弃 code;已废弃 code 只用于历史回显(V13 由读路径降级显示)
|
||||
boolean live = FormStatusDefs.liveDefs(form.getStatusDefs()).stream()
|
||||
@@ -169,16 +179,47 @@ public class AnswerAdminService {
|
||||
StatusLog entry = new StatusLog();
|
||||
entry.setAnswerId(answer.getId());
|
||||
entry.setStatusCode(status);
|
||||
entry.setOperatorId(operatorId);
|
||||
entry.setOperatorId(operator.getId());
|
||||
statusLogRepository.save(entry);
|
||||
// 留痕与单条完全一致(方案 §5.4):一条答卷一行;批量通道只在 op 与 summary 上标出来自批量
|
||||
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS,
|
||||
operatorId, (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
|
||||
operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
|
||||
+ "(无转移规则校验)");
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
/**
|
||||
* 定位 + 行级范围把关(P30 的落点)。
|
||||
*
|
||||
* <p>顺序是刻意的:先按 {@code (域, uuid)} 定位(跨校答卷查不到,T3 已有的第一道),
|
||||
* 再用<b>四条读出路径共用的范围构造器</b>判"这条在不在你的数据范围内"。
|
||||
* 判定用 SQL({@link AnswerScopeQueryBuilder#visible})而不是在 Java 里比字段——
|
||||
* 与列表口径必然一致,因此"列表能看见的"与"能改的"是同一个集合,不存在两套判据漂移。</p>
|
||||
*/
|
||||
private Answer requireManageableAnswer(BoaUser operator, Long schId, String answerUuid) {
|
||||
Answer answer = requireLiveAnswer(schId, answerUuid);
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
ReadModels.Query scope = readService.scopeQuery(operator, schId, pinned.form(),
|
||||
new ReadModels.QueryRequest());
|
||||
if (!queryBuilder.visible(scope, answer.getUuid())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"该答卷不在你的数据范围内(契约 §8 矩阵),不能执行管理动作:" + answerUuid);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
private void assertSubjectStillValid(Answer answer) {
|
||||
Integer subjectId = answer.getSubjectId();
|
||||
if (subjectId == null || subjectId.equals(answer.getSubmitterId())) {
|
||||
return;
|
||||
}
|
||||
if (!subjectDirectory.existsInSchool(subjectId, answer.getSchId())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"该答卷的被代填人已不存在或已不属本校,不能编辑留档:subjectId=" + subjectId);
|
||||
}
|
||||
}
|
||||
|
||||
private Answer requireLiveAnswer(Long schId, String answerUuid) {
|
||||
if (answerUuid == null || answerUuid.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
|
||||
@@ -191,11 +232,25 @@ public class AnswerAdminService {
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件归属与一致性(P24 同校 + P34 key 一致),与提交侧 {@code AnswerService} 同一判据:
|
||||
* 值里的 {@code {id,fileName,key}} 必须是同校上传接口返回的原样三件。
|
||||
*/
|
||||
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
|
||||
for (Long id : FormValueValidator.attachmentFileIds(valuesJson, schemaJson)) {
|
||||
if (!fileRepository.findByIdAndSchId(id, schId).isPresent()) {
|
||||
for (JsonNode item : FormValueValidator.attachments(valuesJson, schemaJson)) {
|
||||
long id = item.get("id").longValue();
|
||||
FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
|
||||
if (file == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件不存在或不属于本校:id=" + id);
|
||||
}
|
||||
String key = item.path("key").asText(null);
|
||||
if (key == null || !key.equals(file.getStoreKey())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件 key 与上传记录不一致:id=" + id);
|
||||
}
|
||||
String fileName = item.path("fileName").asText(null);
|
||||
if (fileName == null || !fileName.equals(file.getFileName())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "附件文件名与上传记录不一致:id=" + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,5 +282,4 @@ public class AnswerAdminService {
|
||||
return AnswerModels.BatchItemView.fail(answerUuid, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.alibaba.excel.ExcelWriter;
|
||||
import com.alibaba.excel.write.metadata.WriteSheet;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.common.FormSchemaValidator;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.repository.AnswerRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 答卷导出(方案 §5.5 铁律④ / V37):<b>范围恒为单表单 + 可选筛选</b>,流式分批逐批写 easyexcel,
|
||||
* 同步响应(不设行数上限、不做异步导出/下载中心——真实规模 1 千~2 万行级,没必要)。
|
||||
*
|
||||
* <p>三条纪律:</p>
|
||||
* <ul>
|
||||
* <li><b>受数据范围约束</b>:查询规格与谓词都来自四读出路径共用的 {@link AnswerScopeQueryBuilder}
|
||||
* (与 {@link #stats} 同源,见类内实现),所以"导出 ⊆ 该身份列表可见集"是结构性成立的,
|
||||
* 不是靠导出侧再判一遍;</li>
|
||||
* <li><b>行数=该范围下的总数</b>(不是列表当页):分批迭代靠 keyset 游标推进(每批
|
||||
* {@value #BATCH_SIZE} 行),全程不载入全量、也不 {@code select values} 做扫描;</li>
|
||||
* <li><b>内容按各行钉住版本渲染</b>:选项列显 label(按该版本 options 定义序,契约 §3)、
|
||||
* 附件列显 fileName(值里本来就只有 {@code {id,fileName,key}},不拼 URL——V24)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnswerExportService {
|
||||
|
||||
/** 每批行数(流式写的批大小;与列表页大小不同轴,导出要吞吐所以放大) */
|
||||
static final int BATCH_SIZE = 500;
|
||||
|
||||
private static final String SEPARATOR = "、";
|
||||
|
||||
private final AnswerReadService readService;
|
||||
private final AnswerScopeQueryBuilder queryBuilder;
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final AnswerRepository answerRepository;
|
||||
|
||||
/**
|
||||
* 导出到输出流(调用方=控制器,负责响应头与文件名;本方法写完关闭 ExcelWriter)。
|
||||
*
|
||||
* @return 实际写出的数据行数(控制器日志与 V37 断言用)
|
||||
*/
|
||||
public long export(BoaUser user, Long domainSchId, ReadModels.QueryRequest req, OutputStream out) {
|
||||
Form form = defService.readDomainForm(domainSchId, req.getFormUuid());
|
||||
ReadModels.Query query = readService.scopeQuery(user, domainSchId, form, req);
|
||||
query.setSize(BATCH_SIZE);
|
||||
query.setAscending(false);
|
||||
query.setCursorId(null);
|
||||
|
||||
// 列集合=当前发布版本的字段序(方案 §5.2"列表列=最新 schema ∪ 历史 searchable"里,
|
||||
// 导出按最新 schema 出列,历史版本独有字段的值仍在该行的 values 文档里,按行钉版渲染 label)
|
||||
Map<String, JsonNode> currentFields = fieldsByVersion(form, form.getCurrentVersion());
|
||||
List<String> columnKeys = new ArrayList<>(currentFields.keySet());
|
||||
|
||||
ExcelWriter writer = EasyExcel.write(out).head(head(columnKeys, currentFields)).build();
|
||||
WriteSheet sheet = EasyExcel.writerSheet("答卷").build();
|
||||
long written = 0L;
|
||||
int pageSize = AnswerScopeQueryBuilder.pageSize(query);
|
||||
Map<Integer, Map<String, JsonNode>> versionFields = new HashMap<>();
|
||||
try {
|
||||
while (true) {
|
||||
List<Long> ids = queryBuilder.pageIds(query);
|
||||
if (ids.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
Map<Long, Answer> loaded = new HashMap<>();
|
||||
for (Answer answer : answerRepository.findLiveByIds(domainSchId, ids)) {
|
||||
loaded.put(answer.getId(), answer);
|
||||
}
|
||||
List<List<Object>> batch = new ArrayList<>(ids.size());
|
||||
Long lastId = null;
|
||||
for (Long id : ids) {
|
||||
Answer answer = loaded.get(id);
|
||||
if (answer == null) {
|
||||
continue;
|
||||
}
|
||||
batch.add(rowOf(answer, columnKeys, fieldsForVersion(form, answer, versionFields)));
|
||||
lastId = id;
|
||||
}
|
||||
if (!batch.isEmpty()) {
|
||||
writer.write(batch, sheet);
|
||||
written += batch.size();
|
||||
}
|
||||
if (lastId == null || ids.size() < pageSize) {
|
||||
// 末批(不足一页)写完即止;否则游标前移继续下一批(keyset,无 offset)
|
||||
break;
|
||||
}
|
||||
query.setCursorId(lastId);
|
||||
}
|
||||
if (written == 0) {
|
||||
// 空结果也要出一份带表头的合法 xlsx:不写这一次的话 easyexcel 连 sheet 都不建,
|
||||
// 消费方拿到的是"Can not find any sheet"的坏文件(受限身份范围为空是正常情形)
|
||||
writer.write(new ArrayList<>(), sheet);
|
||||
}
|
||||
} finally {
|
||||
writer.finish();
|
||||
}
|
||||
log.info("[导出] form={}/{} 范围={} 写出 {} 行", form.getAppCode(), form.getCode(),
|
||||
query.getScopes(), written);
|
||||
return written;
|
||||
}
|
||||
|
||||
// ==================== 列与单元格 ====================
|
||||
|
||||
/** 表头:固定六列(身份/时间)+ 当前发布版本字段序(数组序即展示序,契约 §2) */
|
||||
private List<List<String>> head(List<String> columnKeys, Map<String, JsonNode> currentFields) {
|
||||
List<List<String>> head = new ArrayList<>();
|
||||
head.add(single("答卷ID"));
|
||||
head.add(single("报名号"));
|
||||
head.add(single("状态"));
|
||||
head.add(single("提交人ID"));
|
||||
head.add(single("被代填人ID"));
|
||||
head.add(single("提交时间"));
|
||||
for (String key : columnKeys) {
|
||||
JsonNode field = currentFields.get(key);
|
||||
String label = field == null ? key : field.path("label").asText(key);
|
||||
head.add(single(label + "(" + key + ")"));
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
private List<Object> rowOf(Answer answer, List<String> columnKeys, Map<String, JsonNode> pinnedFields) {
|
||||
List<Object> row = new ArrayList<>();
|
||||
row.add(answer.getUuid());
|
||||
row.add(answer.getAnswerNo());
|
||||
row.add(answer.getStatus());
|
||||
row.add(answer.getSubmitterId());
|
||||
row.add(answer.getSubjectId());
|
||||
row.add(AnswerReadService.format(answer.getCreateTime()));
|
||||
JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏");
|
||||
for (String key : columnKeys) {
|
||||
JsonNode field = pinnedFields.get(key);
|
||||
row.add(renderCell(values.get(key), field));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格渲染:选项类回查 label(按该钉住版本 options 定义序,多选按定义序而非勾选序,契约 §3);
|
||||
* 附件类取 fileName(值里没有 URL,V24);其它原样字符串化。
|
||||
* 已删字段(该版本 schema 里没有)不产出列,值文档仍可在详情接口取到。
|
||||
*/
|
||||
private String renderCell(JsonNode value, JsonNode field) {
|
||||
if (value == null || value.isNull() || field == null) {
|
||||
return "";
|
||||
}
|
||||
String type = field.path("type").asText();
|
||||
if ("upload".equals(type) || "image".equals(type)) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (JsonNode item : value) {
|
||||
String fileName = item.path("fileName").asText(null);
|
||||
if (fileName != null) {
|
||||
names.add(fileName);
|
||||
}
|
||||
}
|
||||
return join(names);
|
||||
}
|
||||
if ("checkbox".equals(type) || "multiselect".equals(type)) {
|
||||
List<String> labels = optionLabelsInDefinitionOrder(field, value);
|
||||
return join(labels.isEmpty() ? textList(value) : labels);
|
||||
}
|
||||
if ("select".equals(type) || "radio".equals(type)) {
|
||||
String label = optionLabel(field, value.asText());
|
||||
return label == null ? value.asText() : label;
|
||||
}
|
||||
return value.asText();
|
||||
}
|
||||
|
||||
/** 多选展示顺序按 options 定义序(契约 §3:不按勾选序) */
|
||||
private List<String> optionLabelsInDefinitionOrder(JsonNode field, JsonNode value) {
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<String> picked = new ArrayList<>();
|
||||
for (JsonNode item : value) {
|
||||
picked.add(item.asText());
|
||||
}
|
||||
JsonNode options = field.get("options");
|
||||
if (options == null || !options.isArray()) {
|
||||
return labels;
|
||||
}
|
||||
for (JsonNode option : options) {
|
||||
if (picked.contains(option.path("value").asText())) {
|
||||
labels.add(option.path("label").asText(option.path("value").asText()));
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private String optionLabel(JsonNode field, String value) {
|
||||
JsonNode options = field.get("options");
|
||||
if (options == null || !options.isArray()) {
|
||||
return null;
|
||||
}
|
||||
for (JsonNode option : options) {
|
||||
if (value.equals(option.path("value").asText())) {
|
||||
return option.path("label").asText(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<String> textList(JsonNode value) {
|
||||
List<String> texts = new ArrayList<>();
|
||||
for (JsonNode item : value) {
|
||||
texts.add(item.asText());
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
private static String join(List<String> parts) {
|
||||
return String.join(SEPARATOR, parts);
|
||||
}
|
||||
|
||||
private static List<String> single(String title) {
|
||||
List<String> column = new ArrayList<>(1);
|
||||
column.add(title);
|
||||
return column;
|
||||
}
|
||||
|
||||
// ==================== 版本 schema 缓存(一次导出内按版本号复用) ====================
|
||||
|
||||
/** 该答卷钉住版本的字段表(options 已内联展开,才能把 value 回查成 label) */
|
||||
private Map<String, JsonNode> fieldsForVersion(Form form, Answer answer,
|
||||
Map<Integer, Map<String, JsonNode>> memo) {
|
||||
Map<String, JsonNode> cached = memo.get(answer.getVersion());
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Map<String, JsonNode> fields = fieldsByVersion(form, answer.getVersion());
|
||||
memo.put(answer.getVersion(), fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定版本的字段表;版本行缺失(历史脏数据)时返回空表——该行仍会被导出(身份列完整),
|
||||
* 值列留空而不是让整次导出失败。
|
||||
*/
|
||||
private Map<String, JsonNode> fieldsByVersion(Form form, Integer version) {
|
||||
if (version == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
String schemaJson = defService.pinnedForData(form.getUuid(), version).versionRow().getSchemaJson();
|
||||
return FormSchemaValidator.fieldsByKeys(dictService.expandSchema(form.getSchId(), schemaJson));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[导出] 取版本 schema 失败 form={} v{}:{}", form.getUuid(), version, e.getMessage());
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,24 +5,30 @@ 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 com.yunhai.formcenter.repository.AnswerRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@code answer_index} 写入器(方案 §5.2 / 契约 §2 searchable)。
|
||||
*
|
||||
* <p>唯一一条判断口径:<b>只有该答卷钉住版本里显式声明 {@code searchable} 的字段才写行</b>,
|
||||
* 且列按 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)。</p>
|
||||
* <p>唯一一条判断口径:<b>只有显式声明 {@code searchable} 的字段才写行</b>,且列按 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,P28)。</p>
|
||||
*
|
||||
* <p>三个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})。
|
||||
* <p>四个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})、
|
||||
* 发布升版后补建({@link #backfillAfterPublish})。
|
||||
* <b>不存在</b>"按新版本 schema 清理旧字段行"的入口——那会打掉 V8 的"删字段后历史仍可筛"。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -30,13 +36,94 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class AnswerIndexService {
|
||||
|
||||
/** 升版补建的批大小(发布是管理员低频动作,一批最多读这么多份值文档进内存) */
|
||||
static final int BACKFILL_BATCH_SIZE = 500;
|
||||
|
||||
private final AnswerIndexRepository indexRepository;
|
||||
private final AnswerRepository answerRepository;
|
||||
|
||||
/** 提交:按钉住版本写行 */
|
||||
public void write(Answer answer, String schemaJson) {
|
||||
indexRepository.saveAll(rowsFor(answer, schemaJson, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* <b>升版发布后给历史答卷补索引行</b>——V9("新增 searchable 字段全程无 DDL,旧数据按新字段筛选正确")
|
||||
* 的实现形态。
|
||||
*
|
||||
* <p>为什么必须补:提交时只按<em>当时钉住版本</em>的 searchable 声明写行,v2 才新声明的字段在 v1
|
||||
* 答卷上没有行,"旧数据按新字段筛"就落不了地。补建是<b>只增不删</b>的,与 V8"新版本删字段不清历史行"
|
||||
* 同向,两条索引生命周期规则不冲突(方案 §5.2)。</p>
|
||||
*
|
||||
* <p>代价记账(登记执行手册 §5 问题单):发布时按 id 游标分批扫本表单未删答卷,
|
||||
* 每批 {@value #BACKFILL_BATCH_SIZE} 份值文档,已有该 field_key 的行不重复插(幂等、可重入)。
|
||||
* 发布是低频管理动作,2 万份量级为秒级;一期不做异步补建半成品。</p>
|
||||
*
|
||||
* @param formUuid 本次发布的表单(头表 uuid,P25 后答卷按它溯源)
|
||||
* @param schemaJson 新版本 schema 原文
|
||||
* @return 补写的行数
|
||||
*/
|
||||
public int backfillAfterPublish(String formUuid, String schemaJson) {
|
||||
List<String> keys = FormSchemaValidator.searchableKeys(schemaJson);
|
||||
if (keys.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int inserted = 0;
|
||||
Long cursor = 0L;
|
||||
while (true) {
|
||||
List<Answer> batch = answerRepository.findLiveByFormUuidAfterId(
|
||||
formUuid, cursor, PageRequest.of(0, BACKFILL_BATCH_SIZE));
|
||||
if (batch.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (Answer answer : batch) {
|
||||
Set<String> held = new HashSet<>(indexRepository.findFieldKeysByAnswerId(answer.getId()));
|
||||
List<AnswerIndex> rows = rowsFor(answer, schemaJson, held);
|
||||
if (!rows.isEmpty()) {
|
||||
indexRepository.saveAll(rows);
|
||||
inserted += rows.size();
|
||||
}
|
||||
}
|
||||
cursor = batch.get(batch.size() - 1).getId();
|
||||
if (batch.size() < BACKFILL_BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (inserted > 0) {
|
||||
log.info("[索引补建] form_uuid={} 升版后补写 answer_index 行 {} 条(V9)", formUuid, inserted);
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
public List<AnswerIndex> rowsOf(Long answerId) {
|
||||
return indexRepository.findByAnswerIdOrderByIdAsc(answerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 schema 的 searchable 声明产出该答卷的索引行。
|
||||
*
|
||||
* @param skipFieldKeys 已有行的字段集合(补建去重用;null=全量产出,提交/重建用)
|
||||
*/
|
||||
private List<AnswerIndex> rowsFor(Answer answer, String schemaJson, Set<String> skipFieldKeys) {
|
||||
List<AnswerIndex> rows = new ArrayList<>();
|
||||
JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏");
|
||||
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
|
||||
for (String fieldKey : FormSchemaValidator.searchableKeys(schemaJson)) {
|
||||
if (skipFieldKeys != null && skipFieldKeys.contains(fieldKey)) {
|
||||
continue;
|
||||
}
|
||||
JsonNode field = fields.get(fieldKey);
|
||||
JsonNode value = values.get(fieldKey);
|
||||
if (field == null || value == null || value.isNull()) {
|
||||
@@ -49,41 +136,26 @@ public class AnswerIndexService {
|
||||
}
|
||||
for (JsonNode item : value) {
|
||||
if (item.isTextual()) {
|
||||
indexRepository.save(row(answer, fieldKey, item.asText(), null, null));
|
||||
rows.add(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));
|
||||
rows.add(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())));
|
||||
rows.add(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));
|
||||
rows.add(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<AnswerIndex> rowsOf(Long answerId) {
|
||||
return indexRepository.findByAnswerIdOrderByIdAsc(answerId);
|
||||
return rows;
|
||||
}
|
||||
|
||||
private AnswerIndex row(Answer answer, String fieldKey, String vStr, BigDecimal vNum, LocalDate vDate) {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.common.FormFilterOperators;
|
||||
import com.yunhai.formcenter.common.FormSchemaValidator;
|
||||
import com.yunhai.formcenter.common.FormStatusDefs;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.repository.AnswerRepository;
|
||||
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.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据面读服务(列表 / 详情 / 筛选候选,契约 §8/§9,方案 §5.5)。
|
||||
*
|
||||
* <p><b>与 {@link AnswerScopeQueryBuilder} 的分工</b>:本类负责"把请求翻译成查询规格"
|
||||
* (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。
|
||||
* 统计({@link AnswerStatsService})与导出({@link AnswerExportService})都调本类的
|
||||
* {@link #scopeQuery} 拿同一份规格——这就是"四条读出路径共用同一个 scope 构造器"的调用形态,
|
||||
* 新增第五个读接口时也只能从这里取规格。</p>
|
||||
*
|
||||
* <p>三处纪律:列表走<b>两段式</b>(先取当页 id、再按 id 批量取值文档,铁律①);
|
||||
* 翻页走 <b>keyset</b> 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
|
||||
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnswerReadService {
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final DataScopeService scopeService;
|
||||
private final AnswerScopeQueryBuilder queryBuilder;
|
||||
private final AnswerRepository answerRepository;
|
||||
|
||||
// ==================== 规格构造(四路径共用) ====================
|
||||
|
||||
/**
|
||||
* 会话身份 + 表单 + 请求 → 读路径查询规格。
|
||||
*
|
||||
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来
|
||||
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的
|
||||
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
|
||||
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
|
||||
*/
|
||||
public ReadModels.Query scopeQuery(BoaUser user, Long domainSchId, Form form, ReadModels.QueryRequest req) {
|
||||
ReadModels.Query query = new ReadModels.Query();
|
||||
query.setSchId(domainSchId);
|
||||
query.setAppCode(form.getAppCode());
|
||||
query.setCode(form.getCode());
|
||||
query.setScopes(scopeService.resolve(user, form));
|
||||
query.setUserId(user == null ? null : user.getId());
|
||||
query.setStatus(req == null ? null : trimToNull(req.getStatus()));
|
||||
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
|
||||
return query;
|
||||
}
|
||||
|
||||
/** 对外游标 uuid → 库内 id(游标本身也要过范围,见构造器) */
|
||||
public void applyCursor(ReadModels.Query query, String cursorUuid, String order, Integer size) {
|
||||
query.setAscending("asc".equalsIgnoreCase(trimToNull(order)));
|
||||
if (size != null && size > 0) {
|
||||
query.setSize(Math.min(size, AnswerScopeQueryBuilder.MAX_PAGE_SIZE));
|
||||
}
|
||||
query.setCursorId(queryBuilder.resolveCursorId(query, cursorUuid));
|
||||
}
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
/**
|
||||
* 两段式列表(铁律①):第一段窄索引取当页 id,第二段按 id 批量取值文档。
|
||||
* 返回顺序=游标顺序(desc 最新在前 / asc 最旧在前),排序列恒为提交时间(铁律②)。
|
||||
*/
|
||||
public ReadModels.ListView list(BoaUser user, Long domainSchId, ReadModels.ListRequest req) {
|
||||
Form form = defService.readDomainForm(domainSchId, req.getFormUuid());
|
||||
ReadModels.Query query = scopeQuery(user, domainSchId, form, req);
|
||||
applyCursor(query, req.getCursor(), req.getOrder(), req.getSize());
|
||||
|
||||
List<Long> ids = queryBuilder.pageIds(query);
|
||||
ReadModels.ListView view = new ReadModels.ListView();
|
||||
view.setScopes(query.getScopes());
|
||||
view.setSize(AnswerScopeQueryBuilder.pageSize(query));
|
||||
if (ids.isEmpty()) {
|
||||
return view;
|
||||
}
|
||||
Map<Long, Answer> loaded = new HashMap<>();
|
||||
for (Answer answer : answerRepository.findLiveByIds(domainSchId, ids)) {
|
||||
loaded.put(answer.getId(), answer);
|
||||
}
|
||||
Answer last = null;
|
||||
for (Long id : ids) {
|
||||
Answer answer = loaded.get(id);
|
||||
if (answer == null) {
|
||||
continue;
|
||||
}
|
||||
view.getRows().add(toRow(answer, form));
|
||||
last = answer;
|
||||
}
|
||||
view.setNextCursor(last == null ? null : last.getUuid());
|
||||
return view;
|
||||
}
|
||||
|
||||
// ==================== 详情 ====================
|
||||
|
||||
/**
|
||||
* 详情(uuid 寻址):返回该答卷<b>钉住版本</b>的 schema 引用(内联展开后的 groups)+ 值文档。
|
||||
* 行级可见性用同一个构造器判(不是"查出来再在 Java 里比")——判定与列表口径必然一致,
|
||||
* 也就必然出现"列表能看到的详情一定能打开、列表看不到的详情打开不了"。
|
||||
*/
|
||||
public ReadModels.DetailView detail(BoaUser user, Long domainSchId, String answerUuid) {
|
||||
Answer answer = requireLiveAnswer(domainSchId, answerUuid);
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
Form form = pinned.form();
|
||||
ReadModels.Query query = scopeQuery(user, domainSchId, form, new ReadModels.QueryRequest());
|
||||
if (!queryBuilder.visible(query, answer.getUuid())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 §8 矩阵):" + answerUuid);
|
||||
}
|
||||
ReadModels.DetailView view = new ReadModels.DetailView();
|
||||
copyInto(answer, form, view);
|
||||
view.setAppCode(form.getAppCode());
|
||||
view.setCode(form.getCode());
|
||||
view.setFormName(form.getName());
|
||||
view.setGroups(groupsNode(dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson())));
|
||||
return view;
|
||||
}
|
||||
|
||||
// ==================== 筛选候选 ====================
|
||||
|
||||
/**
|
||||
* 筛选候选列(V3 + P28):当前发布版本里 searchable 且<b>有算子</b>的字段,
|
||||
* 附按 type 收敛出的算子(前端据此出控件:等值下拉 / 区间双输入 / 前缀文本框)。
|
||||
* 未发布或无当前版本 → 空列表(草稿字段不参与筛选)。
|
||||
*/
|
||||
public List<ReadModels.FilterFieldView> filterFields(Long domainSchId, String formUuid) {
|
||||
Form form = defService.readDomainForm(domainSchId, formUuid);
|
||||
List<ReadModels.FilterFieldView> result = new ArrayList<>();
|
||||
if (form.getCurrentVersion() == null) {
|
||||
return result;
|
||||
}
|
||||
String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
|
||||
.versionRow().getSchemaJson();
|
||||
Map<String, JsonNode> filterable = FormSchemaValidator.filterableFields(schemaJson);
|
||||
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
|
||||
for (String key : filterable.keySet()) {
|
||||
JsonNode field = fields.get(key);
|
||||
ReadModels.FilterFieldView item = new ReadModels.FilterFieldView();
|
||||
item.setKey(key);
|
||||
item.setLabel(field.path("label").asText(key));
|
||||
item.setType(field.path("type").asText());
|
||||
item.setOperator(FormFilterOperators.operatorOf(field.path("type").asText()));
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 行视图(导出与统计复用) ====================
|
||||
|
||||
public ReadModels.RowView toRow(Answer answer, Form form) {
|
||||
ReadModels.RowView row = new ReadModels.RowView();
|
||||
copyInto(answer, form, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void copyInto(Answer answer, Form form, ReadModels.RowView row) {
|
||||
row.setAnswerId(answer.getUuid());
|
||||
row.setFormUuid(answer.getFormUuid());
|
||||
row.setVersion(answer.getVersion());
|
||||
row.setStatus(answer.getStatus());
|
||||
row.setStatusLabel(FormStatusDefs.labelOf(form.getStatusDefs(), answer.getStatus()));
|
||||
row.setSubmitterId(answer.getSubmitterId());
|
||||
row.setSubjectId(answer.getSubjectId());
|
||||
row.setAnswerNo(answer.getAnswerNo());
|
||||
row.setSubmitTime(format(answer.getCreateTime()));
|
||||
row.setValues(FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏"));
|
||||
}
|
||||
|
||||
/** 值文档里的附件/选项原样返回;label 回查(导出用)在 {@link AnswerExportService} */
|
||||
public static String format(LocalDateTime time) {
|
||||
return time == null ? null : TS.format(time);
|
||||
}
|
||||
|
||||
/** 钉住版本的 groups(展开后的数组节点),前端按它渲染历史答卷(契约 §3 三态空值由前端判) */
|
||||
public static JsonNode groupsNode(String expandedSchemaJson) {
|
||||
return FormSchemaValidator.parse(expandedSchemaJson, "表单结构损坏(请联系管理员)").path("groups");
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
/** 请求里的筛选条件 → 算子规格(按当前发布版本收敛);不在候选集里的 key 直接拒 */
|
||||
private List<FormFilterOperators.Spec> resolveFilters(Form form, List<ReadModels.FilterItem> items) {
|
||||
List<FormFilterOperators.Spec> specs = new ArrayList<>();
|
||||
if (items == null || items.isEmpty()) {
|
||||
return specs;
|
||||
}
|
||||
if (form.getCurrentVersion() == null) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "该表单尚未发布,没有可筛选字段:" + form.getCode());
|
||||
}
|
||||
String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
|
||||
.versionRow().getSchemaJson();
|
||||
Map<String, JsonNode> filterable = FormSchemaValidator.filterableFields(schemaJson);
|
||||
for (ReadModels.FilterItem item : items) {
|
||||
if (item == null || item.getKey() == null || item.getKey().trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "筛选条件缺少字段 key");
|
||||
}
|
||||
String key = item.getKey().trim();
|
||||
JsonNode field = filterable.get(key);
|
||||
if (field == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"字段不可作为筛选条件(未声明 searchable,或该类型无检索算子):" + key);
|
||||
}
|
||||
specs.add(FormFilterOperators.resolve(field, item));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
private Answer requireLiveAnswer(Long domainSchId, String answerUuid) {
|
||||
if (answerUuid == null || answerUuid.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
|
||||
}
|
||||
Answer answer = answerRepository.findByUuidAndSchId(answerUuid.trim(), domainSchId).orElse(null);
|
||||
if (answer == null || Integer.valueOf(1).equals(answer.getIsDeleted())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
private static String trimToNull(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String value = raw.trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
/** 分组桶的展示名映射(按状态分组用状态定义;选项类字段用 label 回查) */
|
||||
public Map<String, String> statusLabels(Form form) {
|
||||
Map<String, String> labels = new LinkedHashMap<>();
|
||||
for (FormStatusDefs.StatusDef def : FormStatusDefs.effective(form.getStatusDefs())) {
|
||||
labels.put(def.getCode(), def.getLabel());
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
|
||||
/**
|
||||
* 数据范围<b>实现器</b>(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器,
|
||||
* 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长,
|
||||
* 接口隔离保证读路径不跟着改。
|
||||
*
|
||||
* <p><b>唯一出口约束</b>:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词,
|
||||
* 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上
|
||||
* 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。</p>
|
||||
*/
|
||||
public interface AnswerScopeImplementor {
|
||||
|
||||
/** 本实现器负责的范围值 */
|
||||
DataScope scope();
|
||||
|
||||
/**
|
||||
* 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK)
|
||||
* ——枚举里有、<b>一期不可选</b>(V28),故其实现器返回 false 且不参与解析结果。
|
||||
*/
|
||||
boolean selectable();
|
||||
|
||||
/**
|
||||
* 产出该范围对单条答卷的行级谓词。
|
||||
*
|
||||
* @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
|
||||
* @return 恒真({@code cb.conjunction()})=该范围不额外收窄行
|
||||
*/
|
||||
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.common.FormFilterOperators;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.entity.AnswerIndex;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
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.Repository;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import javax.persistence.criteria.AbstractQuery;
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Expression;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.persistence.criteria.Subquery;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* <b>数据范围查询构造器</b>(方案 §6.4 红线 / 契约 §8 红线的唯一实现处):
|
||||
* 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关,
|
||||
* <em>全部</em>调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、
|
||||
* 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。
|
||||
*
|
||||
* <p>一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)}
|
||||
* → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询)
|
||||
* → keyset 游标。</p>
|
||||
*
|
||||
* <p>四条铁律在本类的落点(方案 §5.5):</p>
|
||||
* <ol>
|
||||
* <li>铁律①:{@link #pageIds} 只 {@code select a.id},<b>不碰 values_json</b>;
|
||||
* 当页整文档由调用方按 id 批量取({@code AnswerRepository#findLiveByIds});</li>
|
||||
* <li>铁律②:游标条件恒落在 {@code id}(提交时间序,与索引同序),没有"按任意列排序"的入口,
|
||||
* 也没有 offset;</li>
|
||||
* <li>铁律③:筛选落 {@code answer_index} 的 {@code (sch_id, code, field_key, v_*, answer_id)}
|
||||
* 覆盖索引子查询;列表/计数落 {@code idx_answer_form_time};按状态分组落 {@code idx_answer_form};</li>
|
||||
* <li>铁律④:导出的分批迭代就是 {@link #pageIds} 的游标逐批前移,不存在一次取全量。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class AnswerScopeQueryBuilder {
|
||||
|
||||
/** 单页上限(超出按上限截,防"一次拉十万行"把两段式变成全量载入) */
|
||||
public static final int MAX_PAGE_SIZE = 100;
|
||||
public static final int DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
private final DataScopeService scopeService;
|
||||
|
||||
/** 第一段:本页答卷的库内 id(窄索引定位,不取 values_json) */
|
||||
public List<Long> pageIds(ReadModels.Query q) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
|
||||
Root<Answer> root = cq.from(Answer.class);
|
||||
cq.select(root.<Long>get("id"));
|
||||
cq.where(cb.and(predicates(cq, cb, root, q).toArray(new Predicate[0])));
|
||||
cq.orderBy(q.isAscending() ? cb.asc(root.get("id")) : cb.desc(root.get("id")));
|
||||
return em.createQuery(cq).setMaxResults(pageSize(q)).getResultList();
|
||||
}
|
||||
|
||||
/** 计数(列表总数、导出行数、管理动作的"这条可不可见"都走它) */
|
||||
public long count(ReadModels.Query q) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
|
||||
Root<Answer> root = cq.from(Answer.class);
|
||||
cq.select(cb.count(root));
|
||||
cq.where(cb.and(predicates(cq, cb, root, q).toArray(new Predicate[0])));
|
||||
Long result = em.createQuery(cq).getSingleResult();
|
||||
return result == null ? 0L : result;
|
||||
}
|
||||
|
||||
/** 该范围内的可见性(详情与管理动作的行级把关:把 uuid 加进同一个构造器再数一次) */
|
||||
public boolean visible(ReadModels.Query q, String answerUuid) {
|
||||
ReadModels.Query probe = copyOf(q);
|
||||
probe.setAnswerUuid(answerUuid);
|
||||
return count(probe) > 0;
|
||||
}
|
||||
|
||||
/** 按状态分组(走 {@code idx_answer_form(sch_id, code, status, id)} 的覆盖分组,不回表) */
|
||||
public Map<String, Long> groupByStatus(ReadModels.Query q) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
|
||||
Root<Answer> root = cq.from(Answer.class);
|
||||
Expression<String> status = root.get("status");
|
||||
cq.multiselect(status, cb.count(root));
|
||||
cq.where(cb.and(predicates(cq, cb, root, q).toArray(new Predicate[0])));
|
||||
cq.groupBy(status);
|
||||
Map<String, Long> result = new LinkedHashMap<>();
|
||||
for (Object[] row : em.createQuery(cq).getResultList()) {
|
||||
result.put((String) row[0], ((Number) row[1]).longValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 searchable 字段分组(V11/V2 的落点):分组事实全在 {@code answer_index},
|
||||
* "哪些答卷可见"用<b>同一个范围构造器</b>作子查询套上去——这就是"统计不是越权后门"的实现依据。
|
||||
*
|
||||
* @param fieldKey 分组字段
|
||||
* @param column 该字段落地的索引列(由字段类型收敛而来)
|
||||
* @return 值 → 答卷数(多值字段按 {@code count(distinct answer_id)},一答卷不因多值重复计数)
|
||||
*/
|
||||
public Map<Object, Long> groupByField(ReadModels.Query q, String fieldKey, FormFilterOperators.Column column) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
|
||||
Root<AnswerIndex> ai = cq.from(AnswerIndex.class);
|
||||
Expression<? extends Comparable> value = comparablePath(ai, column);
|
||||
|
||||
Subquery<Long> visibleAnswers = cq.subquery(Long.class);
|
||||
Root<Answer> answerRoot = visibleAnswers.from(Answer.class);
|
||||
visibleAnswers.select(answerRoot.<Long>get("id"));
|
||||
visibleAnswers.where(cb.and(predicates(visibleAnswers, cb, answerRoot, q).toArray(new Predicate[0])));
|
||||
|
||||
List<Predicate> indexPredicates = new ArrayList<>();
|
||||
indexPredicates.add(cb.equal(ai.get("schId"), q.getSchId()));
|
||||
indexPredicates.add(cb.equal(ai.get("code"), q.getCode()));
|
||||
indexPredicates.add(cb.equal(ai.<String>get("fieldKey"), fieldKey));
|
||||
indexPredicates.add(ai.<Long>get("answerId").in(visibleAnswers));
|
||||
cq.multiselect(value, cb.countDistinct(ai.get("answerId")));
|
||||
cq.where(cb.and(indexPredicates.toArray(new Predicate[0])));
|
||||
cq.groupBy(value);
|
||||
|
||||
Map<Object, Long> result = new LinkedHashMap<>();
|
||||
for (Object[] row : em.createQuery(cq).getResultList()) {
|
||||
if (row[0] == null) {
|
||||
continue;
|
||||
}
|
||||
result.put(row[0], ((Number) row[1]).longValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外游标(uuid)→ 库内 id。游标本身也要过范围:拿一条不可见答卷的 uuid 当起点就等于
|
||||
* 用游标探测他人数据,所以先在同一个构造器里数一次,不可见直接拒。
|
||||
*/
|
||||
public Long resolveCursorId(ReadModels.Query q, String cursorUuid) {
|
||||
if (cursorUuid == null || cursorUuid.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
ReadModels.Query probe = copyOf(q);
|
||||
probe.setAnswerUuid(cursorUuid.trim());
|
||||
if (count(probe) <= 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "游标不可用(不在可见范围内或已删除):" + cursorUuid);
|
||||
}
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
|
||||
Root<Answer> root = cq.from(Answer.class);
|
||||
cq.select(root.<Long>get("id"));
|
||||
cq.where(cb.and(predicates(cq, cb, root, probe).toArray(new Predicate[0])));
|
||||
List<Long> ids = em.createQuery(cq).setMaxResults(1).getResultList();
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
/** 请求页大小:缺省 20,上限 {@value #MAX_PAGE_SIZE}(方案 §5.5:当页 10~20,解析成本可忽略) */
|
||||
public static int pageSize(ReadModels.Query q) {
|
||||
return q.getSize() <= 0 ? DEFAULT_PAGE_SIZE : Math.min(q.getSize(), MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
/** 浅拷贝一份规格(改游标/uuid 时用,避免污染调用方对象) */
|
||||
public static ReadModels.Query copyOf(ReadModels.Query q) {
|
||||
ReadModels.Query copy = new ReadModels.Query();
|
||||
copy.setSchId(q.getSchId());
|
||||
copy.setAppCode(q.getAppCode());
|
||||
copy.setCode(q.getCode());
|
||||
copy.setScopes(q.getScopes());
|
||||
copy.setUserId(q.getUserId());
|
||||
copy.setStatus(q.getStatus());
|
||||
copy.setFilters(q.getFilters());
|
||||
copy.setCursorId(q.getCursorId());
|
||||
copy.setAscending(q.isAscending());
|
||||
copy.setSize(q.getSize());
|
||||
copy.setAnswerUuid(q.getAnswerUuid());
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ==================== 谓词构造(唯一出口) ====================
|
||||
|
||||
private List<Predicate> predicates(AbstractQuery<?> owner, CriteriaBuilder cb, Root<Answer> root,
|
||||
ReadModels.Query q) {
|
||||
if (q.getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "读查询缺少租户域");
|
||||
}
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
ps.add(cb.equal(root.get("schId"), q.getSchId()));
|
||||
ps.add(cb.equal(root.get("appCode"), q.getAppCode()));
|
||||
ps.add(cb.equal(root.get("code"), q.getCode()));
|
||||
// 软删答卷在四条读出路径一律不可见(V30 的读侧半边)
|
||||
ps.add(cb.equal(root.get("isDeleted"), 0));
|
||||
ps.add(scopePredicate(cb, root, q));
|
||||
if (q.getStatus() != null && !q.getStatus().trim().isEmpty()) {
|
||||
ps.add(cb.equal(root.get("status"), q.getStatus().trim()));
|
||||
}
|
||||
if (q.getAnswerUuid() != null && !q.getAnswerUuid().trim().isEmpty()) {
|
||||
ps.add(cb.equal(root.get("uuid"), q.getAnswerUuid().trim()));
|
||||
}
|
||||
if (q.getCursorId() != null) {
|
||||
ps.add(q.isAscending() ? cb.greaterThan(root.get("id"), q.getCursorId())
|
||||
: cb.lessThan(root.get("id"), q.getCursorId()));
|
||||
}
|
||||
for (FormFilterOperators.Spec filter : q.filtersOrEmpty()) {
|
||||
ps.add(filterPredicate(owner, cb, root, q, filter));
|
||||
}
|
||||
return ps;
|
||||
}
|
||||
|
||||
/** 范围段:多来源的并集(OR);命中"全部"即不加行级条件 */
|
||||
private Predicate scopePredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
|
||||
Set<DataScope> scopes = q.getScopes();
|
||||
if (scopes == null || scopes.isEmpty()) {
|
||||
// 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须是仅本人提交
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未解析出数据范围,拒绝按无范围读取");
|
||||
}
|
||||
if (scopes.contains(DataScope.ALL)) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
if (q.getUserId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "数据范围需要会话用户身份");
|
||||
}
|
||||
List<Predicate> ors = new ArrayList<>();
|
||||
for (DataScope scope : scopes) {
|
||||
ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q.getUserId()));
|
||||
}
|
||||
return cb.or(ors.toArray(new Predicate[0]));
|
||||
}
|
||||
|
||||
/** 筛选段:每个条件一个 answer_index 子查询(AND 叠加,多条件在 SQL 层完成——V10) */
|
||||
private Predicate filterPredicate(AbstractQuery<?> owner, CriteriaBuilder cb, Root<Answer> root,
|
||||
ReadModels.Query q, FormFilterOperators.Spec filter) {
|
||||
Subquery<Long> sub = owner.subquery(Long.class);
|
||||
Root<AnswerIndex> ai = sub.from(AnswerIndex.class);
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
ps.add(cb.equal(ai.get("schId"), q.getSchId()));
|
||||
ps.add(cb.equal(ai.get("code"), q.getCode()));
|
||||
ps.add(cb.equal(ai.<String>get("fieldKey"), filter.getKey()));
|
||||
if (filter.getColumn() == FormFilterOperators.Column.STR) {
|
||||
Expression<String> column = ai.get("vStr");
|
||||
ps.add(filter.getKind() == FormFilterOperators.Kind.PREFIX
|
||||
? cb.like(column, likePrefix(filter.getStrValue()), '\\')
|
||||
: cb.equal(column, filter.getStrValue()));
|
||||
} else if (filter.getColumn() == FormFilterOperators.Column.NUM) {
|
||||
Expression<BigDecimal> column = ai.get("vNum");
|
||||
ps.add(cb.between(column, filter.getNumFrom(), filter.getNumTo()));
|
||||
} else {
|
||||
Expression<LocalDate> column = ai.get("vDate");
|
||||
ps.add(cb.between(column, filter.getDateFrom(), filter.getDateTo()));
|
||||
}
|
||||
sub.select(ai.<Long>get("answerId"));
|
||||
sub.where(cb.and(ps.toArray(new Predicate[0])));
|
||||
return root.<Long>get("id").in(sub);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Expression<? extends Comparable> comparablePath(Root<AnswerIndex> ai, FormFilterOperators.Column column) {
|
||||
if (column == FormFilterOperators.Column.NUM) {
|
||||
return (Expression<? extends Comparable>) ai.<BigDecimal>get("vNum");
|
||||
}
|
||||
if (column == FormFilterOperators.Column.DATE) {
|
||||
return (Expression<? extends Comparable>) ai.<LocalDate>get("vDate");
|
||||
}
|
||||
return (Expression<? extends Comparable>) ai.<String>get("vStr");
|
||||
}
|
||||
|
||||
/**
|
||||
* 前缀模糊的 LIKE 模式:先转义 {@code \ % _} 再加尾随 {@code %}。
|
||||
* 不转义时用户输入 {@code "%"} 会把前缀筛选变成全索引扫描(不是越权,但让铁律③的索引选择性失效)。
|
||||
*/
|
||||
public static String likePrefix(String raw) {
|
||||
StringBuilder sb = new StringBuilder(raw.length() + 4);
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (c == '\\' || c == '%' || c == '_') {
|
||||
sb.append('\\');
|
||||
}
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.append('%').toString();
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,15 @@ 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)。一次提交在这条流水线上依次过关:
|
||||
*
|
||||
* <pre>
|
||||
* 钉版解析 → 截止 → 幂等命中 → subject 授权 → 值校验(按钉住版本) → 附件归属
|
||||
* → 限次占位(fill_quota 撞键即拒) → 落 answer → 写 answer_index(只写 searchable)
|
||||
* 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本)
|
||||
* → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid)
|
||||
* → 写 answer_index(只写 searchable)
|
||||
* </pre>
|
||||
*
|
||||
* <p><b>为什么顺序是这样</b>:</p>
|
||||
@@ -62,6 +62,7 @@ public class AnswerService {
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final SubjectDirectory subjectDirectory;
|
||||
private final AnswerRepository answerRepository;
|
||||
private final FillQuotaRepository quotaRepository;
|
||||
private final FormFileRepository fileRepository;
|
||||
@@ -113,8 +114,8 @@ public class AnswerService {
|
||||
return toView(existing, true);
|
||||
}
|
||||
|
||||
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)
|
||||
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId);
|
||||
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33)
|
||||
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId);
|
||||
|
||||
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
|
||||
String valuesJson = valuesToJson(req.getValues());
|
||||
@@ -127,6 +128,8 @@ public class AnswerService {
|
||||
// 7) 落答卷
|
||||
Answer answer = new Answer();
|
||||
answer.setUuid(newUuid());
|
||||
// P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行)
|
||||
answer.setFormUuid(form.getUuid());
|
||||
answer.setSchId(schId);
|
||||
answer.setAppCode(form.getAppCode());
|
||||
answer.setCode(form.getCode());
|
||||
@@ -152,7 +155,7 @@ public class AnswerService {
|
||||
|
||||
// ==================== 各环节 ====================
|
||||
|
||||
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId) {
|
||||
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId, Long schId) {
|
||||
if (subjectId == null || subjectId.equals(submitterId)) {
|
||||
return subjectId;
|
||||
}
|
||||
@@ -160,6 +163,12 @@ public class AnswerService {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")");
|
||||
}
|
||||
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
|
||||
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
|
||||
if (!subjectDirectory.existsInSchool(subjectId, schId)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
|
||||
}
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
@@ -195,14 +204,31 @@ public class AnswerService {
|
||||
return prefix + String.format("%04d", seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件归属与一致性(P24 同校归属 + <b>P34 key 一致性</b>)。
|
||||
*
|
||||
* <p>值里的 {@code {id,fileName,key}} 必须是<b>同校上传接口返回的那三件原样</b>:
|
||||
* id 定位到本校的 form_file 行,key 必须等于该行的 store_key——否则"id 是中心侧可定位的附件行"
|
||||
* 这个语义就只是半个(可以挂着别人的 key 渲染)。合法前端永远原样转存返回值,此校验不误伤正常流程。</p>
|
||||
*/
|
||||
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
|
||||
List<Long> ids = FormValueValidator.attachmentFileIds(valuesJson, schemaJson);
|
||||
for (Long id : ids) {
|
||||
for (JsonNode item : FormValueValidator.attachments(valuesJson, schemaJson)) {
|
||||
long id = item.get("id").longValue();
|
||||
FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
|
||||
if (file == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"附件不存在或不属于本校(请先走中心上传接口,值里只存返回的 {id,fileName,key}):id=" + id);
|
||||
}
|
||||
String key = item.path("key").asText(null);
|
||||
if (key == null || !key.equals(file.getStoreKey())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"附件 key 与上传记录不一致(值里的 key 必须是上传接口返回的原值):id=" + id);
|
||||
}
|
||||
String fileName = item.path("fileName").asText(null);
|
||||
if (fileName == null || !fileName.equals(file.getFileName())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"附件文件名与上传记录不一致:id=" + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.common.FormFilterOperators;
|
||||
import com.yunhai.formcenter.common.FormSchemaValidator;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
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.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 统计服务(V11 / 方案 §5.5 铁律③④):总数、按状态、按 searchable 字段分组,
|
||||
* <b>聚合全部在接口侧(SQL)完成,前端不做聚合</b>。
|
||||
*
|
||||
* <p><b>范围口径与列表同源</b>:统计用的查询规格来自 {@link AnswerReadService#scopeQuery}、
|
||||
* 谓词来自 {@link AnswerScopeQueryBuilder}——按字段分组时"哪些答卷可见"是同一个构造器做的子查询。
|
||||
* 这条同源性就是 V27"受限身份的统计 ⊆ 其列表可见集"的实现依据;单独写一条 group by 就是越权后门。</p>
|
||||
*
|
||||
* <p><b>缓存(铁律④的失效策略)</b>:键={@code form_uuid + 当前发布 version + 范围签名 + 过滤条件},
|
||||
* <b>只在升版时换键失效</b>,加短 TTL;<b>不按数据变更失效</b>——提交是高频事件,每次提交清缓存
|
||||
* 等于没有缓存(方案 §5.5 v1.2 修正)。代价=统计容忍 TTL 内的延迟,验收断言据此写
|
||||
* (新答卷落库后 TTL 内统计总数不变是<em>预期行为</em>,不是 bug)。</p>
|
||||
*
|
||||
* <p>缓存范围也收紧到有边界:只缓存<b>无筛选条件</b>的全量统计(方案原话"大表单全量分组统计加缓存"),
|
||||
* 带筛选的组合键不缓存;条目数超上限整体清空,避免无界增长。键里含范围签名——
|
||||
* 否则"管理员算出的全校分布"会被教师命中,那就是缓存版本的越权后门。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnswerStatsService {
|
||||
|
||||
/** 统计结果短 TTL(方案 §5.5"统计容忍 N 分钟延迟",一期取 60s:够吸收一次页面重查,又不至于陈旧) */
|
||||
static final long CACHE_TTL_MILLIS = 60_000L;
|
||||
/** 缓存条目上限(超出整体清空——一期不引 Caffeine,见 T4 报告问题单) */
|
||||
static final int MAX_CACHE_ENTRIES = 256;
|
||||
|
||||
private final AnswerReadService readService;
|
||||
private final AnswerScopeQueryBuilder queryBuilder;
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
|
||||
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 统计。
|
||||
*
|
||||
* @param user 会话用户(范围由它解析)
|
||||
* @param domainSchId 租户域(管理端守卫返回的域=答卷的 sch_id)
|
||||
*/
|
||||
public ReadModels.StatsView stats(BoaUser user, Long domainSchId, ReadModels.QueryRequest req) {
|
||||
Form form = defService.readDomainForm(domainSchId, req.getFormUuid());
|
||||
ReadModels.Query query = readService.scopeQuery(user, domainSchId, form, req);
|
||||
boolean cacheable = (req.getFilters() == null || req.getFilters().isEmpty())
|
||||
&& (req.getStatus() == null || req.getStatus().trim().isEmpty());
|
||||
String key = cacheKey(domainSchId, form, user, query, req);
|
||||
if (cacheable) {
|
||||
Cached hit = cache.get(key);
|
||||
if (hit != null && !hit.expired()) {
|
||||
return hit.copyAsCached();
|
||||
}
|
||||
}
|
||||
|
||||
ReadModels.StatsView view = new ReadModels.StatsView();
|
||||
view.setTotal(queryBuilder.count(query));
|
||||
view.setByStatus(statusBuckets(form, queryBuilder.groupByStatus(query)));
|
||||
String fieldKey = trimToNull(req.getGroupByField());
|
||||
if (fieldKey != null) {
|
||||
view.getByField().add(fieldGroups(form, query, fieldKey));
|
||||
}
|
||||
view.setCached(cacheable ? Boolean.FALSE : null);
|
||||
if (cacheable) {
|
||||
put(key, view);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
// ==================== 分组 ====================
|
||||
|
||||
/** 按状态分组:桶=状态 code,label 取自状态定义(含已废弃,V13 由前端标"已废弃";定义里没有=null) */
|
||||
private List<ReadModels.CountItem> statusBuckets(Form form, Map<String, Long> counts) {
|
||||
Map<String, String> labels = readService.statusLabels(form);
|
||||
List<ReadModels.CountItem> items = new ArrayList<>();
|
||||
for (Map.Entry<String, Long> entry : counts.entrySet()) {
|
||||
items.add(new ReadModels.CountItem(entry.getKey(), labels.get(entry.getKey()), entry.getValue()));
|
||||
}
|
||||
sortDescByCount(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 searchable 字段分组(走 answer_index,V11/V38)。
|
||||
* 选项类字段的桶 label 回查 options 定义(改 label 不改 value,历史统计值不变——V2 的依据);
|
||||
* 数值/日期/文本类的桶就是原始值(一期不做分桶区间,那是发明语义)。
|
||||
*/
|
||||
private ReadModels.FieldGroups fieldGroups(Form form, ReadModels.Query query, String fieldKey) {
|
||||
String schemaJson = currentSchema(form);
|
||||
Map<String, JsonNode> filterable = FormSchemaValidator.filterableFields(schemaJson);
|
||||
JsonNode field = filterable.get(fieldKey);
|
||||
if (field == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"分组字段不可用(未声明 searchable 或该类型无检索算子):" + fieldKey);
|
||||
}
|
||||
FormFilterOperators.Column column = FormFilterOperators.columnOf(field.path("type").asText());
|
||||
if (column == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该字段一期不支持分组统计:" + field.path("type").asText());
|
||||
}
|
||||
Map<String, String> optionLabels = optionLabels(field);
|
||||
Map<Object, Long> buckets = queryBuilder.groupByField(query, fieldKey, column);
|
||||
List<ReadModels.CountItem> items = new ArrayList<>();
|
||||
for (Map.Entry<Object, Long> entry : buckets.entrySet()) {
|
||||
String text = render(entry.getKey(), column);
|
||||
items.add(new ReadModels.CountItem(text, optionLabels.get(text), entry.getValue()));
|
||||
}
|
||||
sortDescByCount(items);
|
||||
ReadModels.FieldGroups groups = new ReadModels.FieldGroups();
|
||||
groups.setKey(fieldKey);
|
||||
groups.setLabel(field.path("label").asText(fieldKey));
|
||||
groups.setItems(items);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 选项值 → label(含字典引用展开后的 options;软删条目在展开结果里已被剔除,历史桶因此 label=null) */
|
||||
private Map<String, String> optionLabels(JsonNode field) {
|
||||
Map<String, String> labels = new LinkedHashMap<>();
|
||||
JsonNode options = field.get("options");
|
||||
if (options == null || !options.isArray()) {
|
||||
return labels;
|
||||
}
|
||||
for (JsonNode option : options) {
|
||||
String value = option.path("value").asText(null);
|
||||
if (value != null) {
|
||||
labels.put(value, option.path("label").asText(value));
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ==================== 缓存 ====================
|
||||
|
||||
private String cacheKey(Long domainSchId, Form form, BoaUser user, ReadModels.Query query,
|
||||
ReadModels.QueryRequest req) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(domainSchId).append('|')
|
||||
.append(form.getUuid()).append('@').append(form.getCurrentVersion()).append('|')
|
||||
.append(query.getScopes()).append('|')
|
||||
.append(user == null ? "-" : user.getId()).append('|')
|
||||
.append(trimToNull(req.getGroupByField()));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void put(String key, ReadModels.StatsView view) {
|
||||
if (cache.size() >= MAX_CACHE_ENTRIES) {
|
||||
cache.clear();
|
||||
}
|
||||
cache.put(key, new Cached(view, System.currentTimeMillis() + CACHE_TTL_MILLIS));
|
||||
}
|
||||
|
||||
/** 统计缓存条目:值 + 到期时刻(仅升版换键失效 + 短 TTL,不按数据变更失效——类注释) */
|
||||
private static final class Cached {
|
||||
private final ReadModels.StatsView view;
|
||||
private final long expireAt;
|
||||
|
||||
private Cached(ReadModels.StatsView view, long expireAt) {
|
||||
this.view = view;
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
|
||||
private boolean expired() {
|
||||
return System.currentTimeMillis() > expireAt;
|
||||
}
|
||||
|
||||
/** 命中时包一层新对象返回:不把 cached 标记写回缓存里的共享实例(并发请求会互相改) */
|
||||
private ReadModels.StatsView copyAsCached() {
|
||||
ReadModels.StatsView copy = new ReadModels.StatsView();
|
||||
copy.setTotal(view.getTotal());
|
||||
copy.setByStatus(new ArrayList<>(view.getByStatus()));
|
||||
copy.setByField(new ArrayList<>(view.getByField()));
|
||||
copy.setCached(Boolean.TRUE);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
private String currentSchema(Form form) {
|
||||
if (form.getCurrentVersion() == null) {
|
||||
return "{\"groups\":[]}";
|
||||
}
|
||||
String raw = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
|
||||
.versionRow().getSchemaJson();
|
||||
// 选项 label 回查要的是展开后的 options(字典条目改 label 历史 value 不变,V2/V38)
|
||||
return dictService.expandSchema(form.getSchId(), raw);
|
||||
}
|
||||
|
||||
private static String render(Object value, FormFilterOperators.Column column) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (column == FormFilterOperators.Column.NUM) {
|
||||
BigDecimal number = (BigDecimal) value;
|
||||
return number.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
if (column == FormFilterOperators.Column.DATE) {
|
||||
return ((LocalDate) value).toString();
|
||||
}
|
||||
return String.valueOf(value);
|
||||
}
|
||||
|
||||
private static void sortDescByCount(List<ReadModels.CountItem> items) {
|
||||
Collections.sort(items, (a, b) -> {
|
||||
int byCount = Long.compare(b.getCount(), a.getCount());
|
||||
if (byCount != 0) {
|
||||
return byCount;
|
||||
}
|
||||
String left = a.getValue() == null ? "" : a.getValue();
|
||||
String right = b.getValue() == null ? "" : b.getValue();
|
||||
return left.compareTo(right);
|
||||
});
|
||||
}
|
||||
|
||||
private static String trimToNull(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String value = raw.trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.common.FormModuleKeys;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
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.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围,
|
||||
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
|
||||
*
|
||||
* <p><b>解析规则(优先级)</b>:{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵},
|
||||
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
|
||||
*
|
||||
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
|
||||
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
|
||||
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
|
||||
*
|
||||
* <p><b>身份默认矩阵</b>(契约 §8,行=roleId、列=表单面向人群):</p>
|
||||
* <pre>
|
||||
* 面向学生(1) 面向教师(2)
|
||||
* 7/2 全部 全部
|
||||
* 1 任教班级 → 一期实现器缺席=仅本人提交 仅本人提交
|
||||
* 3 关于我 仅本人提交(该格契约写"不授填报模块行,进不来",
|
||||
* 数据面按最保守的兜底值处理)
|
||||
* 4/6/5 仅本人提交 仅本人提交
|
||||
* </pre>
|
||||
*
|
||||
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(授权模块行),
|
||||
* 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求),
|
||||
* 但一个是 path、一个是 customId,不要混用。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DataScopeService {
|
||||
|
||||
/** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */
|
||||
public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all";
|
||||
|
||||
/** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
|
||||
private static final int ROLE_TEACHER = 1;
|
||||
private static final int ROLE_SCHOOL_LEADER = 2;
|
||||
private static final int ROLE_STUDENT = 3;
|
||||
private static final int ROLE_ADMIN = 7;
|
||||
|
||||
private final ModuleTreeSource moduleTreeSource;
|
||||
private final List<AnswerScopeImplementor> implementors;
|
||||
|
||||
/**
|
||||
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
|
||||
*
|
||||
* @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理
|
||||
*/
|
||||
public Set<DataScope> resolve(BoaUser user, Form form) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) {
|
||||
return EnumSet.of(DataScope.ALL);
|
||||
}
|
||||
Set<DataScope> scopes = new LinkedHashSet<>();
|
||||
// 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认)
|
||||
if (hasScopeAllAnchor(user)) {
|
||||
scopes.add(DataScope.ALL);
|
||||
}
|
||||
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
|
||||
int audience = form == null || form.getAudience() == null
|
||||
? FormEnums.Audience.STUDENT.getCode() : form.getAudience();
|
||||
for (Integer roleId : identityRoleIds(user)) {
|
||||
scopes.add(defaultScope(roleId, audience));
|
||||
}
|
||||
if (scopes.isEmpty()) {
|
||||
scopes.add(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
if (scopes.contains(DataScope.ALL)) {
|
||||
// 命中"全部"即收敛:其余范围是它的真子集,并集里留着只会让读路径多写几个 OR
|
||||
return EnumSet.of(DataScope.ALL);
|
||||
}
|
||||
return EnumSet.copyOf(scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里,
|
||||
* 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。
|
||||
*/
|
||||
public Set<DataScope> selectableScopes() {
|
||||
Set<DataScope> result = new LinkedHashSet<>();
|
||||
for (AnswerScopeImplementor implementor : implementors) {
|
||||
if (implementor.selectable()) {
|
||||
result.add(implementor.scope());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 范围 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
|
||||
public AnswerScopeImplementor implementorOf(DataScope scope) {
|
||||
for (AnswerScopeImplementor implementor : implementors) {
|
||||
if (implementor.scope() == scope) {
|
||||
return implementor;
|
||||
}
|
||||
}
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
|
||||
}
|
||||
|
||||
/** 是否命中 fc:scope:all 权限锚点(path 空的模块行,只由后端扫描授权集合消费,不进导航) */
|
||||
private boolean hasScopeAllAnchor(BoaUser user) {
|
||||
return FormModuleKeys.customIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与矩阵解析的身份:{@code roleIds}(用户全部正常态角色)并上会话 {@code currentRole.roleId}
|
||||
* ——会话身份理论上必在 roleIds 内,并一次是防 UserBase 两侧不一致时漏掉当前身份那一格。
|
||||
*/
|
||||
private List<Integer> identityRoleIds(BoaUser user) {
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
if (user.getRoleIds() != null) {
|
||||
ids.addAll(user.getRoleIds());
|
||||
}
|
||||
Integer current = user.getCurrentRole() == null ? null : user.getCurrentRole().getRoleId();
|
||||
if (current != null && !ids.contains(current)) {
|
||||
ids.add(current);
|
||||
}
|
||||
return ids.isEmpty() ? Collections.<Integer>emptyList() : ids;
|
||||
}
|
||||
|
||||
/** 矩阵取格(任教班级在一期落到"仅本人提交",见类注释表格) */
|
||||
private DataScope defaultScope(Integer roleId, int audience) {
|
||||
boolean studentFacing = FormEnums.Audience.STUDENT.getCode() == audience;
|
||||
if (roleId == null) {
|
||||
return DataScope.SELF_SUBMITTED;
|
||||
}
|
||||
if (roleId == ROLE_ADMIN || roleId == ROLE_SCHOOL_LEADER) {
|
||||
return DataScope.ALL;
|
||||
}
|
||||
if (roleId == ROLE_TEACHER) {
|
||||
if (!studentFacing) {
|
||||
return DataScope.SELF_SUBMITTED;
|
||||
}
|
||||
// 面向学生:矩阵值=任教班级,一期实现器缺席 → 生效值=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
|
||||
log.debug("[数据范围] 教师×面向学生:任教班级一期未开放,按矩阵注记生效为仅本人提交");
|
||||
return DataScope.SELF_SUBMITTED;
|
||||
}
|
||||
if (roleId == ROLE_STUDENT && studentFacing) {
|
||||
return DataScope.ABOUT_ME;
|
||||
}
|
||||
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
|
||||
return DataScope.SELF_SUBMITTED;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,59 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.common.FormModuleKeys;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 管理面守卫(方案 §6.5:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。
|
||||
* 管理面守卫(方案 §6.1 第②层 / §6.5 / 执行手册 §2.2)。
|
||||
*
|
||||
* <p><b>过渡态</b>:方案 §6.1 第②层的权威载体是"授权模块集"(表单设计/数据管理/字典管理模块行),
|
||||
* 那要等 T4 消费 GetUserMod 授权树后才能实现;在那之前按平台标准角色闸门把关,
|
||||
* 口径=执行手册 §5 <b>P21 裁决</b>(T2 验收时对 flow-center 模板做的唯一一处收紧):</p>
|
||||
* <p><b>T4 起口径=授权模块集</b>(P21 裁决的到期替换:T2/T3 的 roleId∈{2,7} 硬编码整体移除、不保留回落)——
|
||||
* "中心管理员"就是<b>被授权对应模块行的人</b>,与平台其它子应用同构(左导航与后端把关用的是同一棵树,
|
||||
* 因此不存在"菜单看得见、接口调不动"的两套口径):</p>
|
||||
* <ul>
|
||||
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN}):可跨校,
|
||||
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份;</li>
|
||||
* <li>校级:{@code roleId ∈ {2 校领导, 7 管理员}} 限本校——
|
||||
* <b>局领导(5) 不给管理权</b>(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。</li>
|
||||
* <li>定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
|
||||
* 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。</li>
|
||||
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、
|
||||
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定,
|
||||
* 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@link #canManage} 是同一个判定的"不抛异常"形态,供契约 §5 的 subject 授权分支使用
|
||||
* (普通用户 {@code subject_id} 只能=自己,有管理权者可代填他人,V17)。</p>
|
||||
* <p><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + customId 决定
|
||||
* ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
|
||||
* 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FormAdminGuard {
|
||||
|
||||
/** 超管用户类型(UserBase UserTypeEnum.SUPER_ADMIN) */
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
/** P21 收紧:校级只认 2(校领导)/7(管理员),局领导 5 已移出 */
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 7);
|
||||
|
||||
/** 定义管理模块行 path(手册 §2.2:表单设计) */
|
||||
public static final String MODULE_FORM_DESIGN = "/admin/forms";
|
||||
/** 数据管理模块行 path */
|
||||
public static final String MODULE_DATA_MANAGE = "/admin/data";
|
||||
/** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */
|
||||
public static final String MODULE_STATS = "/admin/stats";
|
||||
/** 字典管理模块行 path */
|
||||
public static final String MODULE_DICT = "/admin/dicts";
|
||||
|
||||
private final ModuleTreeSource moduleTreeSource;
|
||||
|
||||
/**
|
||||
* 校验管理权并返回本次操作的租户域(定义面与数据面共用)。
|
||||
* 校验"被授权了 {@code modulePath} 这个模块行"并返回本次操作的租户域。
|
||||
*
|
||||
* @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行)
|
||||
* @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒
|
||||
* @param modulePath 需要的模块行 path(本类的四个 {@code MODULE_*} 常量之一)
|
||||
*/
|
||||
public Long requireAdmin(BoaUser user, Long targetSchId) {
|
||||
public Long requireAdmin(BoaUser user, Long targetSchId, String modulePath) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
@@ -53,8 +68,13 @@ public class FormAdminGuard {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
|
||||
}
|
||||
Long current = currentSchId(user);
|
||||
if (!hasSchoolAdminRole(user) || current == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无表单管理权限");
|
||||
if (!hasModule(user, modulePath)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无该模块权限:" + modulePath);
|
||||
}
|
||||
if (current == null) {
|
||||
// 授权树是按 (userId, schId 上下文) 取的,无学校归属时返回的树不可信;
|
||||
// 更关键的是这里必须返回一个"本校"域,给不出就不能放行
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定管理域");
|
||||
}
|
||||
if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) {
|
||||
// 平台级行=跨校资产:校级管理员把目标域传成 0,先按"平台级不归学校管"讲清楚,
|
||||
@@ -68,24 +88,68 @@ public class FormAdminGuard {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否具备管理权(超管或校级管理员)。<b>不含租户域核对</b>——
|
||||
* 是否具备管理权(超管,或被授权定义管理/数据管理任一模块行)。<b>不含租户域核对</b>——
|
||||
* 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。
|
||||
*
|
||||
* <p>现用途=契约 §5 的代填分支({@code subject_id} 能否指向他人):普通用户只能=自己,
|
||||
* 有管理权者可代填(方案 §6.3/V17)。口径与 P21 过渡期的 roleId∈{2,7} 等宽(定义或数据管理),
|
||||
* 判定依据换成授权模块集。</p>
|
||||
*/
|
||||
public boolean canManage(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return isSuperAdmin(user) || (hasSchoolAdminRole(user) && currentSchId(user) != null);
|
||||
if (isSuperAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
if (currentSchId(user) == null) {
|
||||
return false;
|
||||
}
|
||||
Set<String> paths = authorizedPaths(user);
|
||||
return paths.contains(MODULE_DATA_MANAGE) || paths.contains(MODULE_FORM_DESIGN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一条校验的"任一模块行命中即放行"形态。
|
||||
*
|
||||
* <p>现用途=统计:手册 §2.2 明示统计行"可并入数据管理,二选一",所以两种配法都算有统计权;
|
||||
* 只有一个模块行要求的接口请用 {@link #requireAdmin}。</p>
|
||||
*/
|
||||
public Long requireAdminAny(BoaUser user, Long targetSchId, String... modulePaths) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
if (modulePaths == null || modulePaths.length == 0) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模块行清单为空(调用方写错了)");
|
||||
}
|
||||
if (isSuperAdmin(user)) {
|
||||
return requireAdmin(user, targetSchId, modulePaths[0]);
|
||||
}
|
||||
Set<String> held = authorizedPaths(user);
|
||||
for (String modulePath : modulePaths) {
|
||||
if (held.contains(modulePath)) {
|
||||
// 命中即按该模块行走完整租户域校验(复用同一段拒绝逻辑,不在这里另写一份域判定)
|
||||
return requireAdmin(user, targetSchId, modulePath);
|
||||
}
|
||||
}
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无该模块权限(需任一):" + String.join(" 或 ", modulePaths));
|
||||
}
|
||||
|
||||
/** 会话用户被授权的模块行 path 集合(数据范围解析与调试取证用) */
|
||||
public Set<String> authorizedPaths(BoaUser user) {
|
||||
return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user));
|
||||
}
|
||||
|
||||
/** 是否被授权某个模块行(path 精确匹配;超管直接放行) */
|
||||
private boolean hasModule(BoaUser user, String modulePath) {
|
||||
return isSuperAdmin(user) || authorizedPaths(user).contains(modulePath);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -56,8 +56,9 @@ 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 位余量。
|
||||
* 开报名号时的 code 长度上限:{@code -yyyyMMdd-} 占 10 位(两个连字符 + 8 位日期),
|
||||
* 当日序号预留 5 位(单日超万)→ {@code 64-10-5=49},保证 {@code answer_no varchar(64)}
|
||||
* (契约 §10)装得下。开关处拦住,好过提交当天报库错。
|
||||
*/
|
||||
static final int MAX_CODE_LEN_FOR_ANSWER_NO = 49;
|
||||
|
||||
@@ -69,6 +70,7 @@ public class FormDefService {
|
||||
private final FormRepository formRepository;
|
||||
private final FormVersionRepository versionRepository;
|
||||
private final FormDictService dictService;
|
||||
private final AnswerIndexService indexService;
|
||||
|
||||
// ==================== 读 ====================
|
||||
|
||||
@@ -180,44 +182,53 @@ public class FormDefService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面解析<b>答卷钉住的版本行</b>(T3 编辑/软删/改状态用):按同一条校域遮蔽规则取头表,
|
||||
* 再取该版本行;<b>不校验生命周期</b>——方案 §5.4"停用只断填报入口、数据读改接口不变",
|
||||
* 数据面解析<b>答卷钉住的版本行</b>(编辑/软删/改状态 + T4 全部读路径):
|
||||
* 按 {@code answer.form_uuid} → {@code form}(uk_form_uuid) → 该头表下 {@code version} 的行,
|
||||
* <b>精确取数、不经遮蔽规则、不校验生命周期</b>——方案 §5.4"停用只断填报入口、数据读改接口不变",
|
||||
* 升版期间的旧版答卷同样必须可管理。
|
||||
*
|
||||
* <p>一处歧义窗口按"退回平台级行"处理:答卷的钉版坐标是 {@code (会话校, app_code, code, version)}
|
||||
* (契约 §1 校域不变式:{@code answer.sch_id} 恒=会话校),若它当初投的是<b>平台级表单</b>、
|
||||
* 而本校后来建了同 {@code (app_code, code)} 的表单,遮蔽规则会把解析指向本校表单且版本号可能对不上——
|
||||
* 此时退回平台级同 code 表单的版本行,历史答卷仍按原 schema 校验。
|
||||
* 根因与替代方案见 T3 报告问题单。</p>
|
||||
* <p><b>为什么按 form_uuid 而不是 (校, app_code, code, version)</b>(P25 裁决,替代 T3 的回退启发式):
|
||||
* 那组坐标不含"表单归属校",平台级表单被本校后建的同 {@code (app_code,code)} 表单遮蔽后会出现歧义窗口,
|
||||
* 版本号恰好对上时历史答卷被<b>静默按错 schema</b> 校验/重建索引——比查不到更难发现。
|
||||
* uuid 跨版本不变(契约 §1),按它取数没有这个窗口;代价是历史行必须有该列,
|
||||
* 本轮开发库答卷零行、无回填(系统未上线=零迁移成本窗口,H3 同款论证)。</p>
|
||||
*
|
||||
* <p>本方法<b>不过滤 {@code form.is_deleted}</b>:表单软删是唯一切除形态且无物理删除,
|
||||
* 存量答卷的定义溯源不能因为表单被软删而断掉(P32/方案 §5.4)。</p>
|
||||
*/
|
||||
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);
|
||||
public Published pinnedForData(String formUuid, Integer version) {
|
||||
if (formUuid == null || formUuid.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "答卷缺少 form_uuid,无法溯源钉住版本");
|
||||
}
|
||||
Form form = formRepository.findByUuid(formUuid.trim()).orElse(null);
|
||||
if (form == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + code);
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "答卷归属的表单已不存在:" + formUuid);
|
||||
}
|
||||
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);
|
||||
"答卷钉住的版本行不存在:" + form.getAppCode() + "/" + form.getCode() + " v" + version);
|
||||
}
|
||||
return new Published(form, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面读路径的表单定位(T4 列表/统计/导出/筛选候选):按 {@code form_uuid} 取头表,
|
||||
* 域=会话校 ∪ 平台级(契约 §1)——答卷按 {@code sch_id} 租户隔离,表单本身可能是本校的或平台级的。
|
||||
* 他校表单与不存在的表单同一口径拒绝(uuid 不可枚举,差别信息就是探测面,同 {@link #requireForm})。
|
||||
*/
|
||||
public Form readDomainForm(Long sessionSchId, String formUuid) {
|
||||
if (formUuid == null || formUuid.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空");
|
||||
}
|
||||
Form form = formRepository.findByUuid(formUuid.trim()).orElse(null);
|
||||
if (form == null || form.getSchId() == null
|
||||
|| !(form.getSchId().equals(sessionSchId) || form.getSchId() == Form.PLATFORM_SCH_ID)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
// ==================== 写:草稿/发布/生命周期 ====================
|
||||
|
||||
/** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
|
||||
@@ -314,6 +325,9 @@ public class FormDefService {
|
||||
form.setCurrentVersion(draft.getVersion());
|
||||
form.setStatus(FormEnums.FormStatus.PUBLISHED.getCode());
|
||||
formRepository.save(form);
|
||||
// V9:新版本新声明的 searchable 字段,要在历史答卷上补出索引行,否则"旧数据按新字段筛"落不了地。
|
||||
// 只增不删(与 V8 的"新版本删字段不清历史行"同向),幂等可重入。见 AnswerIndexService 注释。
|
||||
indexService.backfillAfterPublish(form.getUuid(), draft.getSchemaJson());
|
||||
return toView(form, null);
|
||||
}
|
||||
|
||||
@@ -380,8 +394,8 @@ public class FormDefService {
|
||||
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。开关处拦住,好过提交当天报库错。
|
||||
// 报名号形态 {code}-{yyyyMMdd}-{当日序号} 必须装得进 answer_no varchar(64)(契约 §10):
|
||||
// -yyyyMMdd- 占 10 位、序号预留 5 位,code 上限因此是 49(MAX_CODE_LEN_FOR_ANSWER_NO)。
|
||||
if (form.getCode().length() > MAX_CODE_LEN_FOR_ANSWER_NO) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"开启报名号要求 code ≤" + MAX_CODE_LEN_FOR_ANSWER_NO + " 位(当前 "
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会话用户在本应用下的<b>已授权模块树</b>来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
|
||||
*
|
||||
* <p>T4 起两类判断都从这棵树出,<b>共用同一个接口</b>(执行手册 §5 P21/P30 裁决落地口径):</p>
|
||||
* <ul>
|
||||
* <li><b>管理权</b>:定义管理/数据管理/统计/字典管理=被授权对应模块行(path 命中,手册 §2.2 表);</li>
|
||||
* <li><b>数据范围</b>:{@code fc:scope:all} 授权锚点=这棵树里 path 空、customId 为该值的纯权限位
|
||||
* (契约 §8 扩展协议)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。
|
||||
* 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责);
|
||||
* 本地 IT 不启动 UserBase/CAS,注入桩即可断言权限矩阵本身,真实链路 T9 联调验。</p>
|
||||
*
|
||||
* <p><b>失败口径=fail-closed</b>:不可达或异常按"无任何授权模块"处理(管理权被拒、范围回落到仅本人提交),
|
||||
* 与契约 §8"全不命中=仅本人提交(宁紧勿松)"同向。</p>
|
||||
*/
|
||||
public interface ModuleTreeSource {
|
||||
|
||||
/** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */
|
||||
List<BoaModule> authorizedModules(BoaUser user);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.common.FormSchemaValidator;
|
||||
import com.yunhai.formcenter.common.FormValueSchemaBuilder;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.formcenter.model.OapiModels;
|
||||
import com.yunhai.formcenter.repository.AnswerRepository;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 一期对外读接口服务(契约 §9 两端点)。
|
||||
*
|
||||
* <p><b>身份与租户</b>:{@code schId} 是<b>调用方声明的租户域</b>(应用签名已核实调用方身份,
|
||||
* 与 flow-center {@code /oapi/flow/**} 同一口径:"应用签名不替代谁能看"——消费方对自己侧的
|
||||
* 用户可见性负责,本层只保证不跨校取数:答卷按 {@code (uuid, sch_id)} 定位,跨校一律查不到,
|
||||
* 表单按 resolve 的"会话校 ∪ 平台级、本校遮蔽"规则解析。</p>
|
||||
*
|
||||
* <p><b>与 CAS 读路径的分工</b>:契约 §8 的行级数据范围矩阵挂在<b>会话身份</b>上,oapi 面没有会话用户
|
||||
* (签名方是应用),因此不走 {@link AnswerScopeQueryBuilder}——它给的是"这张表的结构"与"这一条答卷",
|
||||
* 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死(一期对外只读单条与 schema,
|
||||
* 不提供全量拉取)。这一条写清楚,避免后来人"顺手"加一个 /oapi/form/list。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OapiFormService {
|
||||
|
||||
/** 契约 §9 只给 schId+code 两个参数,归属应用缺省=form-center(手册 §1);快应用表单显式传 appCode */
|
||||
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
|
||||
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final AnswerRepository answerRepository;
|
||||
|
||||
/**
|
||||
* 机读 schema(契约 §4 形状):字典已内联展开,每个字段附 valueSchema。
|
||||
*
|
||||
* @param schId 租户域(调用方声明)
|
||||
* @param appCode 归属应用,可空=form-center
|
||||
* @param code 表单 code
|
||||
*/
|
||||
public OapiModels.SchemaView schema(Long schId, String appCode, String code) {
|
||||
requireSchId(schId);
|
||||
FormDefService.Published published = defService.resolvePublished(schId,
|
||||
appCode == null || appCode.trim().isEmpty() ? DEFAULT_APP_CODE : appCode.trim(), code);
|
||||
Form form = published.form();
|
||||
JsonNode root = FormValueSchemaBuilder.withValueSchemas(expandedRoot(form, published.versionRow().getSchemaJson()));
|
||||
|
||||
OapiModels.SchemaView view = new OapiModels.SchemaView();
|
||||
view.setFormUuid(form.getUuid());
|
||||
view.setVersion(published.versionRow().getVersion());
|
||||
view.setCode(form.getCode());
|
||||
view.setAppCode(form.getAppCode());
|
||||
view.setAudience(FormEnums.Audience.nameOf(form.getAudience()));
|
||||
view.setGroups(root.path("groups"));
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条答卷(契约 §9):钉住版本的内联 schema + 值文档。
|
||||
* 软删答卷对外不可见(与四条 CAS 读路径同一口径,V30)。
|
||||
*/
|
||||
public OapiModels.AnswerView answer(Long schId, String answerId) {
|
||||
requireSchId(schId);
|
||||
if (answerId == null || answerId.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
|
||||
}
|
||||
Answer answer = answerRepository.findByUuidAndSchId(answerId.trim(), schId).orElse(null);
|
||||
if (answer == null || Integer.valueOf(1).equals(answer.getIsDeleted())) {
|
||||
// 与 CAS 面同口径:不存在与跨校同一回答,不给探测面(契约 §1 uuid 不可枚举)
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在:" + answerId);
|
||||
}
|
||||
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
|
||||
Form form = pinned.form();
|
||||
JsonNode root = expandedRoot(form, pinned.versionRow().getSchemaJson());
|
||||
|
||||
OapiModels.AnswerView view = new OapiModels.AnswerView();
|
||||
view.setAnswerId(answer.getUuid());
|
||||
view.setFormUuid(answer.getFormUuid());
|
||||
view.setAppCode(answer.getAppCode());
|
||||
view.setCode(answer.getCode());
|
||||
view.setVersion(answer.getVersion());
|
||||
view.setStatus(answer.getStatus());
|
||||
view.setSubmitterId(answer.getSubmitterId());
|
||||
view.setSubjectId(answer.getSubjectId());
|
||||
view.setAnswerNo(answer.getAnswerNo());
|
||||
view.setSubmitTime(AnswerReadService.format(answer.getCreateTime()));
|
||||
view.setValues(FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏"));
|
||||
OapiModels.SchemaRef ref = new OapiModels.SchemaRef();
|
||||
ref.setFormUuid(form.getUuid());
|
||||
ref.setAppCode(form.getAppCode());
|
||||
ref.setCode(form.getCode());
|
||||
ref.setVersion(answer.getVersion());
|
||||
view.setSchemaRef(ref);
|
||||
view.setGroups(root.path("groups"));
|
||||
return view;
|
||||
}
|
||||
|
||||
/** 字典内联展开 → JSON 树(对外自包含,契约 §2.1) */
|
||||
private JsonNode expandedRoot(Form form, String schemaJson) {
|
||||
return FormSchemaValidator.parse(dictService.expandSchema(form.getSchId(), schemaJson),
|
||||
"表单结构损坏(请联系管理员)");
|
||||
}
|
||||
|
||||
private void requireSchId(Long schId) {
|
||||
if (schId == null || schId < 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "schId 必填(调用方声明的租户域,≥0)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link ModuleTreeSource} 的生产实现:平台标准签名调用 UserBase {@code /oapi/user/modules}
|
||||
* (经 SDK {@code YschBaseOpenApi.getUserModule(userId, schId)},与前端壳 GetUserMod 同一条链路、
|
||||
* 同一个数据源——管理权与导航菜单因此不会有两套口径,方案 §6.1 运行时链路)。
|
||||
*
|
||||
* <p>{@code schId} 传会话学校=把授权限定在该学校上下文(UserBase 侧只取该校子树内的真实归属组参与判定);
|
||||
* 无学校归属的身份(区域组/根直属)传 null,按 UserBase 语义取全部归属组。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PlatformModuleTreeSource implements ModuleTreeSource {
|
||||
|
||||
@Override
|
||||
public List<BoaModule> authorizedModules(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
|
||||
try {
|
||||
List<BoaModule> modules = YschBaseOpenApi.getUserModule(user.getId(), schId);
|
||||
return modules == null ? Collections.<BoaModule>emptyList() : modules;
|
||||
} catch (Exception e) {
|
||||
// fail-closed:取不到树就当没有授权——管理权拒、数据范围回落到仅本人提交(接口注释)
|
||||
log.warn("[模块树] UserBase /oapi/user/modules 不可达,按无授权处理 userId={} schId={}:{}",
|
||||
user.getId(), schId, e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link SubjectDirectory} 的生产实现:平台标准签名调用 UserBase {@code GET /oapi/user/byId?id=}
|
||||
* (经 starter 装配的 {@link OpenApiUserClient},签名四件套由出站拦截器负责,本工程不私写签名、
|
||||
* 不直连 UserBase 库)。每次提交只验一个 id,byId 单查够用,不需要批量端点(P33 裁决)。
|
||||
*
|
||||
* <p>失败口径见接口注释:不可达与查不到都返回 false(fail-closed),差异只落在日志上便于排查
|
||||
* ——flow-center {@code AssigneeResolver#requireUserName} 同款分档。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PlatformSubjectDirectory implements SubjectDirectory {
|
||||
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
@Override
|
||||
public boolean existsInSchool(Integer subjectId, Long schId) {
|
||||
if (subjectId == null || schId == null) {
|
||||
return false;
|
||||
}
|
||||
BoaUser subject;
|
||||
try {
|
||||
subject = userClient.getUserById(String.valueOf(subjectId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[代填校验] UserBase 不可达,按查不到处理 subjectId={}:{}", subjectId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
if (subject == null) {
|
||||
log.warn("[代填校验] UserBase 查无此人 subjectId={}", subjectId);
|
||||
return false;
|
||||
}
|
||||
if (!belongsToSchool(subject, schId)) {
|
||||
log.warn("[代填校验] subjectId={} 的学校归属不含 schId={}", subjectId, schId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校归属判定:{@code schools}(用户全部可选身份的学校集合,UserBase 本期起填充)为主,
|
||||
* {@code school}(当前激活学校)与 {@code currentRole.schId} 为兜底——三者任一含目标校即算同校。
|
||||
* 纯函数,脱库可单测。
|
||||
*/
|
||||
static boolean belongsToSchool(BoaUser subject, Long schId) {
|
||||
if (subject == null || schId == null) {
|
||||
return false;
|
||||
}
|
||||
List<BoaGroup> schools = subject.getSchools();
|
||||
if (schools != null) {
|
||||
for (BoaGroup group : schools) {
|
||||
if (group != null && group.getId() != null && group.getId().longValue() == schId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
BoaGroup school = subject.getSchool();
|
||||
if (school != null && school.getId() != null && school.getId().longValue() == schId) {
|
||||
return true;
|
||||
}
|
||||
return subject.getCurrentRole() != null && subject.getCurrentRole().getSchId() != null
|
||||
&& subject.getCurrentRole().getSchId().longValue() == schId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
/**
|
||||
* 范围实现器:<b>仅本人提交</b>({@code submitter_id}=会话用户)。
|
||||
*
|
||||
* <p>这是契约 §8 的兜底值:"全不命中=仅本人提交(宁紧勿松)",也是教师×面向学生表单在 1.5
|
||||
* 之前的实际生效值(任教班级实现器缺席,方案 §6.4 表格里写明了要防"教师怎么只看到自己"被当 bug 报)。
|
||||
* 走 {@code idx_answer_submitter(sch_id, submitter_id)}。</p>
|
||||
*/
|
||||
@Component
|
||||
public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
|
||||
|
||||
@Override
|
||||
public DataScope scope() {
|
||||
return DataScope.SELF_SUBMITTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean selectable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
return cb.equal(root.get("submitterId"), userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
/**
|
||||
* 被代填人({@code answer.subject_id})的存在性与学校归属解析(执行手册 §5 <b>P33 裁决</b>)。
|
||||
*
|
||||
* <p><b>为什么要这层抽象</b>:这个判断的事实源在 UserBase,不在本库。把它做成策略接口而不是
|
||||
* 在业务服务里直连 UserBase 库或自己拼签名,是为了——</p>
|
||||
* <ul>
|
||||
* <li>生产实现走平台标准签名调用({@code GET /oapi/user/byId?id=},签名四件套由 starter 装配的
|
||||
* 出站拦截器负责,本工程不私写签名逻辑);</li>
|
||||
* <li>本地/IT 环境不启动 UserBase 与 CAS,测试注入桩即可断言"查不到 / 跨校 → 拒"这条规则本身,
|
||||
* 真实链路按裁决在 T9 联调验;</li>
|
||||
* <li>V18 的"关于我的"视图依赖 subject id 指向真实存在的人,这道校验是它的前提。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>口径</b>:解析不出或不可达一律按"查不到"处理(fail-closed)。代填是低频动作
|
||||
* (管理员录一条数据),宁可让管理员重试一次,不可把不存在/他校的人挂进本校数据。</p>
|
||||
*/
|
||||
public interface SubjectDirectory {
|
||||
|
||||
/**
|
||||
* 该 id 是否是<b>本校</b>真实存在的用户。
|
||||
*
|
||||
* @param subjectId 被代填人 id
|
||||
* @param schId 会话校(= {@code answer.sch_id},校域不变式)
|
||||
* @return 存在且其学校归属包含 {@code schId} 才 true;查不到、跨校、UserBase 不可达都返回 false
|
||||
*/
|
||||
boolean existsInSchool(Integer subjectId, Long schId);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
/**
|
||||
* 范围实现器:<b>任教班级</b>——1.5 占位(契约 §8 / 方案 §6.4)。
|
||||
*
|
||||
* <p>存在意义就是把 V28 的两半钉在代码里:枚举值<em>在</em>(一期契约即含四值,定了就不变),
|
||||
* 但 {@link #selectable()} 为 false,{@code DataScopeService} 因此永远不会把它放进解析结果,
|
||||
* 教师×面向学生表单在一期按矩阵注明的"实际生效=仅本人提交"落地。</p>
|
||||
*
|
||||
* <p>1.5 开放时的改动面就在本类:接 UserClass(原 BschClass)的任教关系/班级 SDK,把
|
||||
* {@code rowPredicate} 换成"submitter 的任教班级 ∩ 答卷班级列"或按 subject 的班级归属判定,
|
||||
* {@code selectable()} 翻成 true——读路径与管理动作不用改(它们只认 {@link AnswerScopeQueryBuilder})。</p>
|
||||
*/
|
||||
@Component
|
||||
public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
|
||||
|
||||
@Override
|
||||
public DataScope scope() {
|
||||
return DataScope.TEACHING_CLASS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean selectable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
|
||||
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.yunhai.formcenter.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 检索算子收敛单测(契约 §2"算子按 type 收敛,不给自由表达式"+ V4 的日期按日期比 + P28 附件无算子)。
|
||||
*
|
||||
* <p>这一层是"筛选注入面"的关口:请求只能给 {@code {key,value}} 或 {@code {key,from,to}},
|
||||
* 落到哪一列(v_str/v_num/v_date)、用哪种算子(等值/前缀/区间)由字段类型定,
|
||||
* 且解析不出正确类型的值一律拒(不允许"字符串当数字比"或"日期走字典序"那种静默降级)。</p>
|
||||
*/
|
||||
@DisplayName("FormFilterOperators - 算子按类型收敛(V3/V4/P28)")
|
||||
class FormFilterOperatorsTest {
|
||||
|
||||
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"type\":\"text\",\"label\":\"姓名\",\"searchable\":true},"
|
||||
+ "{\"key\":\"grade\",\"type\":\"select\",\"label\":\"年级\",\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"}]},"
|
||||
+ "{\"key\":\"subjects\",\"type\":\"checkbox\",\"label\":\"学科\",\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"}]},"
|
||||
+ "{\"key\":\"score\",\"type\":\"number\",\"label\":\"分数\",\"searchable\":true},"
|
||||
+ "{\"key\":\"birthday\",\"type\":\"date\",\"label\":\"出生日期\",\"searchable\":true},"
|
||||
+ "{\"key\":\"cert\",\"type\":\"upload\",\"label\":\"证书\",\"searchable\":true}"
|
||||
+ "]}]}";
|
||||
|
||||
@Test
|
||||
@DisplayName("选项类等值、文本类前缀、数值与日期区间——候选算子逐类给对(下发前端用于出控件)")
|
||||
void operatorsByType() {
|
||||
assertThat(FormFilterOperators.operatorOf("select")).isEqualTo("EQ");
|
||||
assertThat(FormFilterOperators.operatorOf("radio")).isEqualTo("EQ");
|
||||
assertThat(FormFilterOperators.operatorOf("checkbox")).isEqualTo("EQ");
|
||||
assertThat(FormFilterOperators.operatorOf("multiselect")).isEqualTo("EQ");
|
||||
assertThat(FormFilterOperators.operatorOf("text")).isEqualTo("PREFIX");
|
||||
assertThat(FormFilterOperators.operatorOf("textarea")).isEqualTo("PREFIX");
|
||||
assertThat(FormFilterOperators.operatorOf("phone")).isEqualTo("PREFIX");
|
||||
assertThat(FormFilterOperators.operatorOf("number")).isEqualTo("RANGE");
|
||||
assertThat(FormFilterOperators.operatorOf("date")).isEqualTo("RANGE");
|
||||
assertThat(FormFilterOperators.operatorOf("upload")).isNull();
|
||||
assertThat(FormFilterOperators.operatorOf("image")).isNull();
|
||||
|
||||
assertThat(FormFilterOperators.columnOf("select")).isEqualTo(FormFilterOperators.Column.STR);
|
||||
assertThat(FormFilterOperators.columnOf("text")).isEqualTo(FormFilterOperators.Column.STR);
|
||||
assertThat(FormFilterOperators.columnOf("number")).isEqualTo(FormFilterOperators.Column.NUM);
|
||||
assertThat(FormFilterOperators.columnOf("date")).isEqualTo(FormFilterOperators.Column.DATE);
|
||||
assertThat(FormFilterOperators.columnOf("upload")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V4:date 落 v_date 列且严格 yyyy-MM-dd——2023-1-9 这种形态直接拒,不进字典序比较")
|
||||
void dateIsTypedNotLexical() {
|
||||
FormFilterOperators.Spec spec = FormFilterOperators.resolve(field("birthday"),
|
||||
range("birthday", "2023-01-09", "2023-01-10"));
|
||||
assertThat(spec.getColumn()).isEqualTo(FormFilterOperators.Column.DATE);
|
||||
assertThat(spec.getDateFrom()).isEqualTo(LocalDate.of(2023, 1, 9));
|
||||
assertThat(spec.getDateTo()).isEqualTo(LocalDate.of(2023, 1, 10));
|
||||
assertThat(spec.getKind()).isEqualTo(FormFilterOperators.Kind.RANGE);
|
||||
|
||||
assertThatThrownBy(() -> FormFilterOperators.resolve(field("birthday"),
|
||||
value("birthday", "2023-1-9")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("yyyy-MM-dd");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("number 走 v_num 数值区间;非数字串被拒(不静默转 0 或跳过)")
|
||||
void numberIsNumericRange() {
|
||||
FormFilterOperators.Spec spec = FormFilterOperators.resolve(field("score"),
|
||||
range("score", "70", "80.5"));
|
||||
assertThat(spec.getColumn()).isEqualTo(FormFilterOperators.Column.NUM);
|
||||
assertThat(spec.getNumFrom()).isEqualByComparingTo(new BigDecimal("70"));
|
||||
assertThat(spec.getNumTo()).isEqualByComparingTo(new BigDecimal("80.5"));
|
||||
|
||||
assertThatThrownBy(() -> FormFilterOperators.resolve(field("score"), value("score", "高分")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不是数值");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单值=两端同值的闭区间(value 与 from/to 两种写法都收,SQL 侧只有 between 一条分支)")
|
||||
void singleValueCollapsesToEqualBounds() {
|
||||
FormFilterOperators.Spec number = FormFilterOperators.resolve(field("score"), value("score", "90"));
|
||||
assertThat(number.getKind()).isEqualTo(FormFilterOperators.Kind.EQ);
|
||||
assertThat(number.getNumFrom()).isEqualByComparingTo(number.getNumTo());
|
||||
|
||||
FormFilterOperators.Spec onlyTo = FormFilterOperators.resolve(field("score"), to("score", "90"));
|
||||
assertThat(onlyTo.getNumFrom()).isEqualByComparingTo("90");
|
||||
assertThat(onlyTo.getNumTo()).isEqualByComparingTo("90");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("选项类只认 value、文本类只认 value:给 from/to 而不给 value 一律拒(形态不匹配不猜)")
|
||||
void shapeMismatchIsRejected() {
|
||||
assertThatThrownBy(() -> FormFilterOperators.resolve(field("grade"), to("grade", "g2")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("筛选值不能为空");
|
||||
assertThatThrownBy(() -> FormFilterOperators.resolve(field("name"), to("name", null)))
|
||||
.hasMessageContaining("筛选值不能为空");
|
||||
assertThatThrownBy(() -> FormFilterOperators.resolve(field("score"), to("score", null)))
|
||||
.hasMessageContaining("from/to");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P28:附件类型没有检索算子,按它筛选被点名拒绝(不拿 fileName 冒充可筛值)")
|
||||
void attachmentHasNoOperator() {
|
||||
assertThatThrownBy(() -> FormFilterOperators.resolve(field("cert"), value("cert", "a.jpg")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("无检索算子");
|
||||
}
|
||||
|
||||
/** 候选集里的字段=可筛字段(filterableFields 的排除面在 FormSchemaValidator 侧,这里断言两处一致) */
|
||||
@Test
|
||||
@DisplayName("可筛候选集=searchable 且非附件类(P28 在候选生成处的落点)")
|
||||
void filterableCandidatesExcludeAttachmentTypes() {
|
||||
Map<String, JsonNode> candidates = FormSchemaValidator.filterableFields(SCHEMA);
|
||||
assertThat(candidates.keySet()).contains("name", "grade", "subjects", "score", "birthday")
|
||||
.doesNotContain("cert");
|
||||
assertThat(FormSchemaValidator.searchableKeys(SCHEMA)).contains("cert"); // 索引侧口径不变(只 warn 不写行)
|
||||
}
|
||||
|
||||
private static JsonNode field(String key) {
|
||||
return FormSchemaValidator.fieldsByKeys(SCHEMA).get(key);
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem item(String key) {
|
||||
ReadModels.FilterItem item = new ReadModels.FilterItem();
|
||||
item.setKey(key);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem value(String key, String value) {
|
||||
ReadModels.FilterItem item = item(key);
|
||||
item.setValue(value);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 只给上界(from=null):解析成两端同值的闭区间 */
|
||||
private static ReadModels.FilterItem to(String key, String to) {
|
||||
ReadModels.FilterItem item = item(key);
|
||||
item.setTo(to);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem range(String key, String from, String to) {
|
||||
ReadModels.FilterItem item = item(key);
|
||||
item.setFrom(from);
|
||||
item.setTo(to);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.yunhai.formcenter.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 机读值形状生成单测(契约 §4:schema 读接口每字段附 {@code valueSchema},消费方可用任意
|
||||
* JSON Schema 校验器对齐;V1 的"任取一类控件按示例构造答卷,后端接受"的机读侧依据)。
|
||||
*
|
||||
* <p>刻意只测形状、<b>不测实现细节</b>:类型、enum 来自内联 options、日期带 format、
|
||||
* 附件对象是 {@code {id,fileName,key}} 三件且 {@code additionalProperties:false}(值里绝不含 URL,V24),
|
||||
* 契约没写的约束(multipleOf/uniqueItems)<b>不出现</b>——发明了就是对外承诺。</p>
|
||||
*/
|
||||
@DisplayName("FormValueSchemaBuilder - 契约 §4 valueSchema 形状(V1)")
|
||||
class FormValueSchemaBuilderTest {
|
||||
|
||||
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"reason\",\"type\":\"textarea\",\"label\":\"申报理由\",\"maxLength\":1000},"
|
||||
+ "{\"key\":\"phone\",\"type\":\"phone\",\"label\":\"联系电话\",\"pattern\":\"^\\\\d{11}$\"},"
|
||||
+ "{\"key\":\"score\",\"type\":\"number\",\"label\":\"分数\",\"min\":0,\"max\":100,\"precision\":1},"
|
||||
+ "{\"key\":\"birthday\",\"type\":\"date\",\"label\":\"出生日期\"},"
|
||||
+ "{\"key\":\"grade\",\"type\":\"select\",\"label\":\"年级\",\"required\":true,"
|
||||
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
|
||||
+ "{\"key\":\"subjects\",\"type\":\"checkbox\",\"label\":\"获奖学科\","
|
||||
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]},"
|
||||
+ "{\"key\":\"cert\",\"type\":\"image\",\"label\":\"证书照片\",\"accept\":\".jpg\","
|
||||
+ "\"maxSize\":10,\"maxFiles\":9}"
|
||||
+ "]}]}";
|
||||
|
||||
@Test
|
||||
@DisplayName("文本类=string(带 maxLength/pattern),number=number(带声明过的 min/max)")
|
||||
void textAndNumberShapes() {
|
||||
JsonNode root = FormValueSchemaBuilder.withValueSchemas(FormSchemaValidator.parse(SCHEMA, "x"));
|
||||
assertThat(valueOf(root, "reason").get("type").asText()).isEqualTo("string");
|
||||
assertThat(valueOf(root, "reason").get("maxLength").asInt()).isEqualTo(1000);
|
||||
assertThat(valueOf(root, "phone").get("pattern").asText()).isEqualTo("^\\d{11}$");
|
||||
JsonNode score = valueOf(root, "score");
|
||||
assertThat(score.get("type").asText()).isEqualTo("number");
|
||||
assertThat(score.get("minimum").asInt()).isZero();
|
||||
assertThat(score.get("maximum").asInt()).isEqualTo(100);
|
||||
// precision 是展示口径(四舍五入位数),不是 JSON Schema 的 multipleOf 约束——不发明
|
||||
assertThat(score.has("multipleOf")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("date=string+format:date;选项类=string+enum(enum 取 value 不取 label)")
|
||||
void dateAndOptionShapes() {
|
||||
assertThat(valueOf(root(), "birthday").get("type").asText()).isEqualTo("string");
|
||||
assertThat(valueOf(root(), "birthday").get("format").asText()).isEqualTo("date");
|
||||
JsonNode grade = valueOf(root(), "grade");
|
||||
assertThat(grade.get("type").asText()).isEqualTo("string");
|
||||
assertThat(grade.get("enum")).extracting(JsonNode::asText).containsExactly("g2", "g3");
|
||||
|
||||
JsonNode subjects = valueOf(root(), "subjects");
|
||||
assertThat(subjects.get("type").asText()).isEqualTo("array");
|
||||
assertThat(subjects.path("items").get("type").asText()).isEqualTo("string");
|
||||
assertThat(subjects.path("items").get("enum")).extracting(JsonNode::asText)
|
||||
.containsExactly("math", "phy");
|
||||
// 契约没写"不可重复",不加 uniqueItems
|
||||
assertThat(subjects.has("uniqueItems")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("附件类=array of {id,fileName,key} 且 additionalProperties=false(值里不可能出现 URL——V24)")
|
||||
void attachmentShapeIsTheContractTriple() {
|
||||
JsonNode cert = valueOf(root(), "cert");
|
||||
assertThat(cert.get("type").asText()).isEqualTo("array");
|
||||
JsonNode file = cert.path("items");
|
||||
assertThat(file.get("type").asText()).isEqualTo("object");
|
||||
assertThat(file.path("properties").fieldNames()).toIterable()
|
||||
.containsExactlyInAnyOrder("id", "fileName", "key");
|
||||
assertThat(file.path("properties").path("id").get("type").asText()).isEqualTo("integer");
|
||||
assertThat(file.path("required")).extracting(JsonNode::asText)
|
||||
.containsExactly("id", "fileName", "key");
|
||||
assertThat(file.get("additionalProperties").asBoolean()).isFalse();
|
||||
// maxFiles 是值数组长度上限(契约 §3 每字段 ≤9 个),映射成 maxItems
|
||||
assertThat(cert.get("maxItems").asInt()).isEqualTo(9);
|
||||
assertThat(cert.has("maxSize")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有 options 的选项类字段不写空 enum;未知/二期类型不写 valueSchema 键")
|
||||
void omitsWhatItCannotKnow() {
|
||||
String schema = "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
|
||||
+ "{\"key\":\"addr\",\"type\":\"address\",\"label\":\"地址\"},"
|
||||
+ "{\"key\":\"pick\",\"type\":\"radio\",\"label\":\"无选项\"}]}]}";
|
||||
JsonNode root = FormValueSchemaBuilder.withValueSchemas(FormSchemaValidator.parse(schema, "x"));
|
||||
JsonNode fields = root.path("groups").get(0).path("fields");
|
||||
assertThat(fields.get(0).has("valueSchema")).isFalse(); // address 二期:不给形状
|
||||
JsonNode valueSchema = fields.get(1).path("valueSchema");
|
||||
assertThat(valueSchema.get("type").asText()).isEqualTo("string");
|
||||
assertThat(valueSchema.has("enum")).isFalse(); // 无 options 不编造枚举
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("入参 schema 不被改写(版本行原文逐字节冻结,V7);加工结果是副本")
|
||||
void inputSchemaIsNotMutated() {
|
||||
JsonNode original = FormSchemaValidator.parse(SCHEMA, "x");
|
||||
String before = original.toString();
|
||||
FormValueSchemaBuilder.withValueSchemas(original);
|
||||
assertThat(original.toString()).isEqualTo(before);
|
||||
}
|
||||
|
||||
private JsonNode root() {
|
||||
return FormValueSchemaBuilder.withValueSchemas(FormSchemaValidator.parse(SCHEMA, "x"));
|
||||
}
|
||||
|
||||
private static JsonNode valueOf(JsonNode root, String fieldKey) {
|
||||
for (JsonNode group : root.path("groups")) {
|
||||
for (JsonNode field : group.path("fields")) {
|
||||
if (fieldKey.equals(field.path("key").asText())) {
|
||||
return field.path("valueSchema");
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("测试 schema 里没有字段 " + fieldKey);
|
||||
}
|
||||
}
|
||||
@@ -204,15 +204,18 @@ class FormValueValidatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("attachmentFileIds:只为附件类字段抽取引用 id(服务层据此做同校归属校验)")
|
||||
void extractsAttachmentIdsOnlyFromAttachmentFields() {
|
||||
List<Long> ids = FormValueValidator.attachmentFileIds(
|
||||
@DisplayName("attachments:只为附件类字段抽出完整三件套(服务层按 id 定位行、再比对 key/fileName——P34)")
|
||||
void extractsAttachmentItemsOnlyFromAttachmentFields() {
|
||||
List<com.fasterxml.jackson.databind.JsonNode> files = FormValueValidator.attachments(
|
||||
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(
|
||||
assertThat(files).hasSize(3);
|
||||
assertThat(files).extracting(node -> node.path("id").longValue()).containsExactly(1001L, 1002L, 1003L);
|
||||
assertThat(files).extracting(node -> node.path("key").asText())
|
||||
.containsExactly("k/a.jpg", "k/b.pdf", "k/c.pdf");
|
||||
assertThat(FormValueValidator.attachments(
|
||||
v("\"name\":\"张三\",\"grade\":\"g2\""), SCHEMA)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Arrays;
|
||||
@@ -32,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
* 两个端点保证(本轮代码结构即证据,见 T3 报告 §1)。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("AnswerAdminService - 编辑/软删/改状态/批量(V29/V30/V14/V15)")
|
||||
class AnswerAdminIT extends AnswerTestSupport {
|
||||
@@ -66,7 +68,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
AnswerModels.EditRequest edit = new AnswerModels.EditRequest();
|
||||
edit.setAnswerId(answerId);
|
||||
edit.setValues(json("{\"name\":\"张三改\",\"score\":88,\"remark\":\"管理员修正\"}"));
|
||||
adminService.edit(SCH, operator, edit);
|
||||
adminService.edit(manager(operator), SCH, edit);
|
||||
|
||||
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
|
||||
assertThat(stored.getValuesJson()).contains("张三改").contains("88");
|
||||
@@ -103,7 +105,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
AnswerModels.EditRequest withNewField = new AnswerModels.EditRequest();
|
||||
withNewField.setAnswerId(answerId);
|
||||
withNewField.setValues(json("{\"name\":\"张三\",\"score\":95.5,\"teacher\":\"李老师\"}"));
|
||||
assertThatThrownBy(() -> adminService.edit(SCH, 10001, withNewField))
|
||||
assertThatThrownBy(() -> adminService.edit(manager(), SCH, withNewField))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("未知字段"); // v2 的字段不凭空落在 v1 的答卷上
|
||||
|
||||
@@ -124,12 +126,12 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
AnswerModels.EditRequest clearRequired = new AnswerModels.EditRequest();
|
||||
clearRequired.setAnswerId(answerId);
|
||||
clearRequired.setValues(json("{\"score\":95.5}"));
|
||||
assertThatThrownBy(() -> adminService.edit(SCH, 10001, clearRequired)).hasMessageContaining("必填");
|
||||
assertThatThrownBy(() -> adminService.edit(manager(), SCH, clearRequired)).hasMessageContaining("必填");
|
||||
|
||||
AnswerModels.EditRequest badType = new AnswerModels.EditRequest();
|
||||
badType.setAnswerId(answerId);
|
||||
badType.setValues(json("{\"name\":\"张三\",\"score\":\"高分\"}"));
|
||||
assertThatThrownBy(() -> adminService.edit(SCH, 10001, badType)).hasMessageContaining("必须是数字");
|
||||
assertThatThrownBy(() -> adminService.edit(manager(), SCH, badType)).hasMessageContaining("必须是数字");
|
||||
|
||||
assertThat(opLogRepository.findByAnswerIdOrderByIdAsc(rowId.getId())).isEmpty();
|
||||
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson()).contains("原文");
|
||||
@@ -146,7 +148,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).get();
|
||||
assertThat(indexService.rowsOf(stored.getId())).isNotEmpty();
|
||||
|
||||
adminService.softDelete(SCH, 10001, answerId);
|
||||
adminService.softDelete(manager(), SCH, answerId);
|
||||
|
||||
Answer after = answerRepository.findByUuidAndSchId(answerId, SCH).get();
|
||||
assertThat(after.getIsDeleted()).isEqualTo(1);
|
||||
@@ -156,7 +158,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
// 留痕与答卷行都还在:只有软删标记,没有任何物理删除路径
|
||||
assertThat(answerRepository.findById(after.getId())).isPresent();
|
||||
|
||||
assertThatThrownBy(() -> adminService.softDelete(SCH, 10001, answerId))
|
||||
assertThatThrownBy(() -> adminService.softDelete(manager(), SCH, answerId))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已删除");
|
||||
}
|
||||
|
||||
@@ -167,14 +169,14 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
String uuid = publishForm(code, SCHEMA);
|
||||
String answerId = answerService.submit(plainUser(3), submit(code, VALUES)).getAnswerId();
|
||||
|
||||
assertThatThrownBy(() -> adminService.softDelete(OTHER_SCH, 10001, answerId))
|
||||
assertThatThrownBy(() -> adminService.softDelete(manager(), OTHER_SCH, 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);
|
||||
adminService.edit(manager(), SCH, edit);
|
||||
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson()).contains("删表后仍可修正");
|
||||
}
|
||||
|
||||
@@ -236,10 +238,10 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
|
||||
// 控制器走的就是这一行:无管理权的填表人到不了 service
|
||||
for (BoaUser submitter : Arrays.asList(plainUser(3), plainUser(1))) {
|
||||
assertThatThrownBy(() -> adminGuard.requireAdmin(submitter, null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("无表单管理权限");
|
||||
assertThatThrownBy(() -> adminGuard.requireAdmin(submitter, null, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("无该模块权限");
|
||||
}
|
||||
assertThat(adminGuard.requireAdmin(adminUser(), null)).isEqualTo(SCH);
|
||||
assertThat(adminGuard.requireAdmin(adminUser(), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
}
|
||||
|
||||
// ==================== 批量 ====================
|
||||
@@ -259,7 +261,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
|
||||
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
|
||||
batch.setAnswerIds(Arrays.asList(first, "00000000000000000000000000000000", second));
|
||||
List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(SCH, 10001, withStatus(batch));
|
||||
List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(manager(), SCH, withStatus(batch));
|
||||
|
||||
assertThat(results).hasSize(3);
|
||||
assertThat(results.stream().filter(AnswerModels.BatchItemView::getSuccess).count()).isEqualTo(2);
|
||||
@@ -284,12 +286,12 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
void batchInputGuards() {
|
||||
AnswerModels.BatchRequest empty = new AnswerModels.BatchRequest();
|
||||
empty.setAnswerIds(java.util.Collections.emptyList());
|
||||
assertThatThrownBy(() -> adminService.batchSoftDelete(SCH, 10001, empty))
|
||||
assertThatThrownBy(() -> adminService.batchSoftDelete(manager(), SCH, 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))
|
||||
assertThatThrownBy(() -> adminService.batchSoftDelete(manager(), SCH, tooMany))
|
||||
.hasMessageContaining("单次上限");
|
||||
}
|
||||
|
||||
@@ -303,7 +305,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
|
||||
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
|
||||
batch.setAnswerIds(Arrays.asList(first, second));
|
||||
assertThat(adminService.batchSoftDelete(SCH, 10001, batch))
|
||||
assertThat(adminService.batchSoftDelete(manager(), SCH, batch))
|
||||
.extracting(AnswerModels.BatchItemView::getSuccess).containsOnly(true);
|
||||
|
||||
for (String id : Arrays.asList(first, second)) {
|
||||
@@ -321,7 +323,7 @@ class AnswerAdminIT extends AnswerTestSupport {
|
||||
AnswerModels.ChangeStatusRequest req = new AnswerModels.ChangeStatusRequest();
|
||||
req.setAnswerId(answerId);
|
||||
req.setStatus(status);
|
||||
adminService.changeStatus(SCH, 10001, req);
|
||||
adminService.changeStatus(manager(), SCH, req);
|
||||
}
|
||||
|
||||
private AnswerModels.BatchRequest withStatus(AnswerModels.BatchRequest batch) {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.context.annotation.Import;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -33,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
* 因此 {@code @AfterEach} 必须定点清理本类自建的数据(租户域 990401,不与其它 IT 交叉)。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@DisplayName("AnswerService - 并发兜底(V32:唯一键才是并发下的裁判)")
|
||||
class AnswerConcurrencyIT extends AnswerTestSupport {
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.FormFile;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 导出集成测试(真实 MySQL;自验项 <b>V37</b>)。
|
||||
*
|
||||
* <p>V37 的四个断言点逐条对上:范围=<b>单表单 + 可选筛选</b>;行数与该数据范围下的<b>总数</b>一致
|
||||
* (不是列表当页);<b>选项列显 label、附件列为文件名</b>;<b>受限身份导出 ⊆ 其可见集</b>
|
||||
* (与 V27 同一构造器——这里断言的是"文件里真的只有那几行",而不是服务返回的计数)。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("答卷导出 - easyexcel 流式(V37)")
|
||||
class AnswerExportIT extends AnswerTestSupport {
|
||||
|
||||
private static final String FORM = "t4_export";
|
||||
|
||||
/** grade 是选项类(存 value 出 label)、subjects 是多选(出 label 且按 options 定义序)、cert 是附件(出 fileName) */
|
||||
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,\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"高二年级\",\"value\":\"g2\"},{\"label\":\"高三年级\",\"value\":\"g3\"}]},"
|
||||
+ "{\"key\":\"subjects\",\"label\":\"获奖学科\",\"type\":\"checkbox\",\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"数学\",\"value\":\"math\"},{\"label\":\"物理\",\"value\":\"phy\"}]},"
|
||||
+ "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"image\"}"
|
||||
+ "]}]}";
|
||||
|
||||
@Autowired
|
||||
private AnswerExportService exportService;
|
||||
|
||||
@Autowired
|
||||
private AnswerReadService readService;
|
||||
|
||||
@Autowired
|
||||
private FormFileRepository fileRepository;
|
||||
|
||||
@Test
|
||||
@DisplayName("V37:行数=该范围下的总数(非列表当页);列序=身份列 + schema 展示序")
|
||||
void exportCountEqualsScopeTotal() {
|
||||
BoaUser admin = manager();
|
||||
String formUuid = seed(admin, 25);
|
||||
|
||||
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
|
||||
req.setFormUuid(formUuid);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
long written = exportService.export(admin, SCH, req, out);
|
||||
|
||||
assertThat(written).as("导出行数=范围内的总数,与列表当页大小无关(页大小 20,数据 25 行)")
|
||||
.isEqualTo(25L);
|
||||
List<Map<Integer, String>> rows = XlsxReader.dataRows(out.toByteArray());
|
||||
assertThat(rows).hasSize(25);
|
||||
assertThat(XlsxReader.headers(out.toByteArray()))
|
||||
.containsExactly("答卷ID", "报名号", "状态", "提交人ID", "被代填人ID", "提交时间",
|
||||
"姓名(name)", "年级(grade)", "获奖学科(subjects)", "证书照片(cert)");
|
||||
// 第一段不取值文档,导出按批推进游标:行数与 uuid 集合都应与列表全量一致(同构造器的直接体现)
|
||||
ReadModels.ListRequest list = new ReadModels.ListRequest();
|
||||
list.setFormUuid(formUuid);
|
||||
list.setSize(50);
|
||||
assertThat(readService.list(admin, SCH, list).getRows()).hasSize(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V37 内容正确性:选项列显 label、多选按 options 定义序(不按勾选序)、附件列显 fileName")
|
||||
void optionAndAttachmentCellsAreRendered() {
|
||||
BoaUser admin = manager();
|
||||
String formUuid = seed(admin, 1);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
exportService.export(admin, SCH, request(formUuid), out);
|
||||
|
||||
Map<Integer, String> row = XlsxReader.dataRows(out.toByteArray()).get(0);
|
||||
assertThat(row.get(7)).as("select 列显 label 不是 value").isEqualTo("高二年级");
|
||||
assertThat(row.get(8)).as("多选存 [\"phy\",\"math\"],出\"数学、物理\"(定义序,契约 §3)")
|
||||
.isEqualTo("数学、物理");
|
||||
assertThat(row.get(9)).as("附件列只出文件名(值里本来就没有 URL,V24)").isEqualTo("奖状.jpg");
|
||||
assertThat(row.get(0)).as("第一列是答卷 uuid,不是库内 id").hasSize(32);
|
||||
assertThat(out.toByteArray().length).isPositive();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V37/V27:受限身份导出 ⊆ 其可见集——教师只导得出自己那几条,且与列表可见 uuid 集合完全一致")
|
||||
void exportIsScopeBoundPerIdentity() {
|
||||
BoaUser admin = manager();
|
||||
String formUuid = seed(admin, 6);
|
||||
|
||||
BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1);
|
||||
ByteArrayOutputStream teacherOut = new ByteArrayOutputStream();
|
||||
long teacherRows = exportService.export(teacher, SCH, request(formUuid), teacherOut);
|
||||
|
||||
ReadModels.ListRequest list = new ReadModels.ListRequest();
|
||||
list.setFormUuid(formUuid);
|
||||
list.setSize(50);
|
||||
List<String> visible = new java.util.ArrayList<>();
|
||||
readService.list(teacher, SCH, list).getRows().forEach(r -> visible.add(r.getAnswerId()));
|
||||
|
||||
assertThat(teacherRows).isEqualTo(visible.size());
|
||||
assertThat(XlsxReader.firstColumn(teacherOut.toByteArray())).containsExactlyElementsOf(visible);
|
||||
assertThat(visible).hasSize(3).allMatch(id -> id != null);
|
||||
|
||||
// 没有任何本人数据的身份:导出是"只有表头的合法文件",不是坏文件、也不是全校
|
||||
BoaUser outsider = user(ReadFixture.BUREAU_LEADER, SCH, 5);
|
||||
ByteArrayOutputStream empty = new ByteArrayOutputStream();
|
||||
assertThat(exportService.export(outsider, SCH, request(formUuid), empty)).isZero();
|
||||
assertThat(XlsxReader.dataRows(empty.toByteArray())).isEmpty();
|
||||
assertThat(XlsxReader.headers(empty.toByteArray())).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("导出同样吃筛选条件(单表单+可选筛选);条件不合法与列表一样被拒,不静默导全量")
|
||||
void exportHonoursFiltersAndRejectsBadOnes() {
|
||||
BoaUser admin = manager();
|
||||
String formUuid = seed(admin, 6);
|
||||
|
||||
ReadModels.QueryRequest filtered = request(formUuid);
|
||||
ReadModels.FilterItem grade = new ReadModels.FilterItem();
|
||||
grade.setKey("grade");
|
||||
grade.setValue("g3");
|
||||
filtered.setFilters(Arrays.asList(grade));
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
assertThat(exportService.export(admin, SCH, filtered, out)).isEqualTo(3L);
|
||||
assertThat(XlsxReader.dataRows(out.toByteArray()))
|
||||
.extracting(row -> row.get(7)).containsOnly("高三年级");
|
||||
|
||||
ReadModels.QueryRequest bad = request(formUuid);
|
||||
ReadModels.FilterItem unknown = new ReadModels.FilterItem();
|
||||
unknown.setKey("not_searchable");
|
||||
unknown.setValue("x");
|
||||
bad.setFilters(Arrays.asList(unknown));
|
||||
assertThatThrownBy(() -> exportService.export(admin, SCH, bad, new ByteArrayOutputStream()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可作为筛选条件");
|
||||
}
|
||||
|
||||
// ==================== 数据准备 ====================
|
||||
|
||||
/**
|
||||
* 建表单并发布,管理员身份写 {@code total} 条答卷,其中前 3 条由教师1 提交(范围用例的"他可见集")。
|
||||
*
|
||||
* @return 表单 uuid
|
||||
*/
|
||||
private String seed(BoaUser admin, int total) {
|
||||
String formUuid = publishForm(FORM, SCHEMA);
|
||||
FormFile file = new FormFile();
|
||||
file.setSchId(SCH);
|
||||
file.setFileName("奖状.jpg");
|
||||
file.setStoreKey("form/2026/10/" + newIdemKey() + ".jpg");
|
||||
file.setFileSize(1024L);
|
||||
file.setExt("jpg");
|
||||
file.setCreateTime(LocalDateTime.now());
|
||||
file.setUpdateTime(LocalDateTime.now());
|
||||
file = fileRepository.save(file);
|
||||
|
||||
BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1);
|
||||
for (int i = 0; i < total; i++) {
|
||||
String grade = i % 2 == 0 ? "g2" : "g3";
|
||||
String values = "{\"name\":\"学生" + i + "\",\"grade\":\"" + grade + "\","
|
||||
+ "\"subjects\":[\"phy\",\"math\"],"
|
||||
+ "\"cert\":[{\"id\":" + file.getId() + ",\"fileName\":\"" + file.getFileName()
|
||||
+ "\",\"key\":\"" + file.getStoreKey() + "\"}]}";
|
||||
answerService.submit(i < 3 ? teacher : admin, submit(FORM, values));
|
||||
}
|
||||
return formUuid;
|
||||
}
|
||||
|
||||
private ReadModels.QueryRequest request(String formUuid) {
|
||||
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
|
||||
req.setFormUuid(formUuid);
|
||||
return req;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import com.yunhai.formcenter.model.AnswerModels;
|
||||
import com.yunhai.formcenter.model.FormModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
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.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 数据面读路径集成测试(真实 MySQL;自验项 <b>V9 / V11 / V23 / V27 / V42</b> +
|
||||
* <b>V30 的读侧半边</b>、<b>V39 的范围半边</b>、<b>P25 遮蔽用例</b>、方案 §5.5 铁律①②取证)。
|
||||
*
|
||||
* <p>数据集见 {@link ReadFixture}:两表单(面向学生/面向教师)× 学生/教师/局领导/管理员 × 软删混合。</p>
|
||||
*
|
||||
* <p><b>V27/V42 的硬断言方式</b>:对每个受限身份,把"列表可见集"当作基准集合,
|
||||
* 统计的总数与导出的行数必须<em>等于</em>该集合大小、且导出的 uuid 必须<em>子于</em>该集合——
|
||||
* 这是"四条读出路径共用同一个 scope 构造器"(契约 §8 红线)的可执行表达,
|
||||
* 单独比数值大小不够(数值相等仍可能是不同的行)。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("数据面读路径 - 列表/详情/统计/范围(V9/V11/V23/V27/V30/V39/V42/P25)")
|
||||
class AnswerReadIT extends AnswerTestSupport {
|
||||
|
||||
@Autowired
|
||||
private AnswerReadService readService;
|
||||
|
||||
@Autowired
|
||||
private AnswerStatsService statsService;
|
||||
|
||||
@Autowired
|
||||
private AnswerExportService exportService;
|
||||
|
||||
@Autowired
|
||||
private FormAdminGuard adminGuard;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${spring.jpa.open-in-view}")
|
||||
private Boolean openInView;
|
||||
|
||||
// ==================== 铁律①:两段式,第一段绝不 SELECT values_json ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("铁律①取证:列表第一段只 select id(SQL 里不出现 values_json),第二段才按当页 id 批量取值文档")
|
||||
void listIsTwoPhaseAndLocatorNeverSelectsValuesColumn() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
|
||||
List<String> sqls = captureAnswerSqls(() -> readService.list(manager(), SCH, listRequest(rows.formAUuid)));
|
||||
|
||||
List<String> answerReads = sqls.stream().filter(sql -> sql.contains("from answer ")).collect(Collectors.toList());
|
||||
assertThat(answerReads).as("列表至少有一次 answer 表访问").isNotEmpty();
|
||||
String locator = answerReads.get(0);
|
||||
assertThat(locator).as("第一段:定位当页 id").doesNotContain("values_json").contains("order by");
|
||||
assertThat(answerReads).anyMatch(sql -> sql.contains("values_json")); // 第二段才取文档
|
||||
}
|
||||
|
||||
// ==================== 铁律②:keyset 翻页 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("铁律②:按提交时间序 keyset 翻页不重不漏;游标指向不可见答卷时被拒(不拿游标探测他人数据)")
|
||||
void keysetPagesWithoutOverlapAndCursorIsScopeBound() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
BoaUser admin = manager();
|
||||
|
||||
Set<String> paged = new LinkedHashSet<>();
|
||||
String cursor = null;
|
||||
int pages = 0;
|
||||
while (pages < 10) {
|
||||
ReadModels.ListRequest req = listRequest(rows.formAUuid);
|
||||
req.setCursor(cursor);
|
||||
req.setSize(2);
|
||||
ReadModels.ListView page = readService.list(admin, SCH, req);
|
||||
if (page.getRows().isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (ReadModels.RowView row : page.getRows()) {
|
||||
assertThat(paged.add(row.getAnswerId())).as("翻页出现重复行:游标未正确推进").isTrue();
|
||||
}
|
||||
cursor = page.getNextCursor();
|
||||
pages++;
|
||||
}
|
||||
assertThat(pages).as("五行未删数据按 size=2 应翻 3 页").isEqualTo(3);
|
||||
assertThat(paged).containsExactlyInAnyOrder(rows.student1Self, rows.student2Self,
|
||||
rows.proxyForStudent1, rows.teacher1Self, rows.teacher2Self);
|
||||
|
||||
// 游标用别人范围内的 uuid:不可见即拒(PARAM_ERROR,而不是静默从头开始)
|
||||
String foreign = rows.teacher1Self;
|
||||
ReadModels.ListRequest probe = listRequest(rows.formAUuid);
|
||||
probe.setCursor(foreign);
|
||||
probe.setSize(2);
|
||||
BoaUser student1 = fixture.student1();
|
||||
assertThatThrownBy(() -> readService.list(student1, SCH, probe))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("游标不可用");
|
||||
}
|
||||
|
||||
// ==================== V27/V42:范围贯穿,统计与导出 ⊆ 列表可见集 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 矩阵生效:管理员=全部、教师=仅本人提交、学生=关于我(含老师代填那条)、局领导=仅本人提交")
|
||||
void matrixResolvesPerContractSectionEight() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
|
||||
assertThat(visibleIds(fixture, manager(), rows.formAUuid))
|
||||
.containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, rows.proxyForStudent1,
|
||||
rows.teacher1Self, rows.teacher2Self);
|
||||
// 教师×面向学生:一期实际生效=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
|
||||
assertThat(visibleIds(fixture, fixture.teacher1(), rows.formAUuid))
|
||||
.containsExactly(rows.teacher1Self);
|
||||
// 学生×面向学生=关于我:自填那条 subject 为空看不见,老师代填的看得见(契约 §6.3 两键分立的直接体现)
|
||||
assertThat(visibleIds(fixture, fixture.student1(), rows.formAUuid))
|
||||
.containsExactly(rows.proxyForStudent1);
|
||||
// 局领导(5) 回落仅本人提交:他没填过 → 空集(不是全部)
|
||||
assertThat(visibleIds(fixture, fixture.bureauLeader(), rows.formAUuid)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 扩展位:给组授权 customId=fc:scope:all 的模块行后,该教师范围升级为全部(与矩阵同一条解析)")
|
||||
void scopeAllAnchorUpgradesTeacher() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
BoaUser teacher = fixture.teacher1();
|
||||
assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Self);
|
||||
|
||||
moduleTree.grantCustomId(ReadFixture.TEACHER_1, DataScopeService.SCOPE_ALL_CUSTOM_ID);
|
||||
ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid));
|
||||
assertThat(upgraded.getScopes()).containsExactly(DataScope.ALL);
|
||||
assertThat(upgraded.getRows()).hasSize(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V27/V42 硬断言:每个受限身份的统计总数与导出行数 =其列表可见集大小,导出 uuid ⊆ 可见集")
|
||||
void statsAndExportAreSubsetsOfListForEveryIdentity() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
|
||||
List<BoaUser> identities = Arrays.asList(manager(), fixture.teacher1(), fixture.teacher2(),
|
||||
fixture.student1(), fixture.student2(), fixture.bureauLeader());
|
||||
for (BoaUser user : identities) {
|
||||
Set<String> list = visibleIds(fixture, user, rows.formAUuid);
|
||||
|
||||
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
|
||||
stats.setFormUuid(rows.formAUuid);
|
||||
stats.setGroupByField("grade");
|
||||
ReadModels.StatsView view = statsService.stats(user, SCH, stats);
|
||||
assertThat(view.getTotal()).as("统计总数必须等于列表可见行数(" + scopeName(user) + ")")
|
||||
.isEqualTo(list.size());
|
||||
long statusSum = view.getByStatus().stream().mapToLong(ReadModels.CountItem::getCount).sum();
|
||||
assertThat(statusSum).as("按状态分桶求和也必须等于总数").isEqualTo(list.size());
|
||||
long gradeSum = view.getByField().isEmpty() ? 0L
|
||||
: view.getByField().get(0).getItems().stream().mapToLong(ReadModels.CountItem::getCount).sum();
|
||||
assertThat(gradeSum).as("按字段分组求和 ≤ 总数(一答卷多值也只计一次,distinct)").isLessThanOrEqualTo(list.size());
|
||||
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
ReadModels.QueryRequest exportReq = new ReadModels.QueryRequest();
|
||||
exportReq.setFormUuid(rows.formAUuid);
|
||||
long exported = exportService.export(user, SCH, exportReq, out);
|
||||
assertThat(exported).as("导出行数=该范围下的总数(非列表当页)").isEqualTo(list.size());
|
||||
assertThat(XlsxReader.firstColumn(out.toByteArray()))
|
||||
.as("导出内容必须 ⊆ 列表可见集").isSubsetOf(list);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== V30 读侧半边 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V30:软删答卷在列表/详情/统计/导出四条路径一律不可见")
|
||||
void softDeletedAnswerIsInvisibleOnAllFourPaths() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
BoaUser admin = manager();
|
||||
|
||||
assertThat(visibleIds(fixture, admin, rows.formAUuid)).doesNotContain(rows.softDeleted);
|
||||
|
||||
assertThatThrownBy(() -> readService.detail(admin, SCH, rows.softDeleted))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不存在或已删除");
|
||||
|
||||
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
|
||||
stats.setFormUuid(rows.formAUuid);
|
||||
assertThat(statsService.stats(admin, SCH, stats).getTotal()).isEqualTo(5L);
|
||||
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
ReadModels.QueryRequest exportReq = new ReadModels.QueryRequest();
|
||||
exportReq.setFormUuid(rows.formAUuid);
|
||||
assertThat(exportService.export(admin, SCH, exportReq, out)).isEqualTo(5L);
|
||||
assertThat(XlsxReader.firstColumn(out.toByteArray())).doesNotContain(rows.softDeleted);
|
||||
}
|
||||
|
||||
// ==================== V9:升版声明 searchable 后旧答卷可按新字段筛 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V9:新版本给 advisor 声明 searchable 后,<b>旧答卷</b>可按它筛选(全程无 DDL;靠发布补建索引行)")
|
||||
void searchableFieldAddedInV2FiltersOldAnswers() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
BoaUser admin = manager();
|
||||
|
||||
// v1:advisor 不在筛选候选里(未声明 searchable)
|
||||
assertThat(readService.filterFields(SCH, rows.formAUuid))
|
||||
.extracting(ReadModels.FilterFieldView::getKey)
|
||||
.contains("name", "grade", "score", "birthday")
|
||||
.doesNotContain("advisor");
|
||||
|
||||
fixture.upgradeToV2(rows.formAUuid);
|
||||
|
||||
List<String> candidates = readService.filterFields(SCH, rows.formAUuid)
|
||||
.stream().map(ReadModels.FilterFieldView::getKey).collect(Collectors.toList());
|
||||
assertThat(candidates).as("升版后候选集里出现 advisor(无 DDL,纯 schema 声明)").contains("advisor");
|
||||
|
||||
// 三条 v1 期写的答卷(advisor=李老师)现在能按新字段筛出来——索引行是发布时补建的
|
||||
Set<String> hit = visibleIdsWithFilter(fixture, admin, rows.formAUuid,
|
||||
filter("advisor", "李", null, null));
|
||||
assertThat(hit).containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
|
||||
assertThat(visibleIdsWithFilter(fixture, admin, rows.formAUuid, filter("advisor", "张老师", null, null)))
|
||||
.containsExactlyInAnyOrder(rows.teacher1Self, rows.teacher2Self);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P28:附件字段勾了 searchable 也不进筛选候选(无检索算子),按它筛选被拒")
|
||||
void attachmentFieldsAreNotFilterable() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
|
||||
assertThat(readService.filterFields(SCH, rows.formAUuid))
|
||||
.extracting(ReadModels.FilterFieldView::getKey).doesNotContain("cert");
|
||||
|
||||
ReadModels.ListRequest req = listRequest(rows.formAUuid);
|
||||
req.setFilters(Arrays.asList(filter("cert", "x", null, null)));
|
||||
assertThatThrownBy(() -> readService.list(manager(), SCH, req))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可作为筛选条件");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V3/V4:算子按类型收敛——选项等值、文本前缀、数值与日期区间在 SQL 层完成;未知 key 拒")
|
||||
void operatorsAreTypeBound() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadModels.FilterFieldView grade = null;
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
for (ReadModels.FilterFieldView field : readService.filterFields(SCH, rows.formAUuid)) {
|
||||
if ("grade".equals(field.getKey())) {
|
||||
grade = field;
|
||||
}
|
||||
}
|
||||
assertThat(grade.getOperator()).isEqualTo("EQ");
|
||||
assertThat(readService.filterFields(SCH, rows.formAUuid))
|
||||
.filteredOn(f -> "name".equals(f.getKey())).extracting(ReadModels.FilterFieldView::getOperator)
|
||||
.containsExactly("PREFIX");
|
||||
assertThat(readService.filterFields(SCH, rows.formAUuid))
|
||||
.filteredOn(f -> "birthday".equals(f.getKey())).extracting(ReadModels.FilterFieldView::getOperator)
|
||||
.containsExactly("RANGE");
|
||||
|
||||
// 等值:选项存 value,展示 label 变了也不影响筛选(V2 同族)
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("grade", "g2", null, null)))
|
||||
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Self);
|
||||
// 前缀模糊(且不是 LIKE 注入:传 "%" 不会命中全部)
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("name", "张", null, null)))
|
||||
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1);
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("name", "%", null, null)))
|
||||
.isEmpty();
|
||||
// 数值区间:70~80 含两端
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, range("score", "70", "80")))
|
||||
.containsExactlyInAnyOrder(rows.student2Self, rows.proxyForStudent1);
|
||||
// 日期区间按日期比,不是字典序(V4:2010-03-09 落在 2010-01-01~2010-06-01 内)
|
||||
assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid,
|
||||
range("birthday", "2010-01-01", "2010-06-01")))
|
||||
.containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Self);
|
||||
|
||||
ReadModels.ListRequest unknown = listRequest(rows.formAUuid);
|
||||
unknown.setFilters(Arrays.asList(filter("remark_not_exists", "x", null, null)));
|
||||
assertThatThrownBy(() -> readService.list(manager(), SCH, unknown))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未声明 searchable");
|
||||
|
||||
ReadModels.ListRequest badShape = listRequest(rows.formAUuid);
|
||||
badShape.setFilters(Arrays.asList(filter("grade", null, "g2", null)));
|
||||
assertThatThrownBy(() -> readService.list(manager(), SCH, badShape))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("筛选值不能为空");
|
||||
}
|
||||
|
||||
// ==================== V11:统计三件事 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V11:统计返回总数/按状态/按字段分组(选项桶给 label,前端不做聚合)")
|
||||
void statsReturnsTotalStatusAndFieldGroups() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
BoaUser admin = manager();
|
||||
|
||||
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
|
||||
req.setFormUuid(rows.formAUuid);
|
||||
req.setGroupByField("grade");
|
||||
ReadModels.StatsView view = statsService.stats(admin, SCH, req);
|
||||
|
||||
assertThat(view.getTotal()).isEqualTo(5L);
|
||||
assertThat(view.getByStatus()).hasSize(1);
|
||||
assertThat(view.getByStatus().get(0).getValue()).isEqualTo("pending");
|
||||
assertThat(view.getByStatus().get(0).getLabel()).isEqualTo("待处理");
|
||||
assertThat(view.getByField()).hasSize(1);
|
||||
assertThat(view.getByField().get(0).getKey()).isEqualTo("grade");
|
||||
assertThat(view.getByField().get(0).getItems())
|
||||
.extracting(ReadModels.CountItem::getValue, ReadModels.CountItem::getLabel,
|
||||
ReadModels.CountItem::getCount)
|
||||
.containsExactlyInAnyOrder(
|
||||
org.assertj.core.groups.Tuple.tuple("g2", "高二", 3L),
|
||||
org.assertj.core.groups.Tuple.tuple("g3", "高三", 2L));
|
||||
|
||||
// 状态过滤后再统计:桶与总数一起窄化(同一构造器,不是两条查询)
|
||||
ReadModels.QueryRequest done = new ReadModels.QueryRequest();
|
||||
done.setFormUuid(rows.formAUuid);
|
||||
done.setStatus("done-not-used");
|
||||
assertThat(statsService.stats(admin, SCH, done).getTotal()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("铁律④:统计缓存键含 form_uuid+version、短 TTL,且<b>不按数据变更失效</b>(新答卷落库后 TTL 内总数不变)")
|
||||
void statsCacheIsVersionScopedAndNotInvalidatedByNewData() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
BoaUser admin = manager();
|
||||
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
|
||||
req.setFormUuid(rows.formAUuid);
|
||||
|
||||
ReadModels.StatsView first = statsService.stats(admin, SCH, req);
|
||||
assertThat(first.getTotal()).isEqualTo(5L);
|
||||
assertThat(first.getCached()).isFalse();
|
||||
|
||||
fixture.submit(fixture.student1(), ReadFixture.FORM_A,
|
||||
"{\"name\":\"新写的\",\"grade\":\"g2\",\"score\":50,\"birthday\":\"2010-05-05\","
|
||||
+ "\"advisor\":\"李老师\"}");
|
||||
|
||||
ReadModels.StatsView second = statsService.stats(admin, SCH, req);
|
||||
assertThat(second.getTotal()).as("提交是高频事件,按数据变更清缓存等于没有缓存(方案 §5.5 v1.2 修正)")
|
||||
.isEqualTo(5L);
|
||||
assertThat(second.getCached()).isTrue();
|
||||
|
||||
// 但列表是实时的(缓存只覆盖统计,读数据不缓存)
|
||||
assertThat(readService.list(admin, SCH, listRequest(rows.formAUuid)).getRows()).hasSize(6);
|
||||
|
||||
// 换身份(范围签名不同)不会命中同一缓存——否则管理员的全校分布就漏给教师了
|
||||
ReadModels.StatsView teacherView = statsService.stats(fixture.teacher1(), SCH, req);
|
||||
assertThat(teacherView.getTotal()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
// ==================== 详情(V5 形状 + V23) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("详情:uuid 寻址、返回钉住版本的 schema 与值文档;open-in-view=false 下无懒加载异常(V23/V5)")
|
||||
void detailReturnsPinnedSchemaAndValues() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
assertThat(openInView).as("V23 的前提:视图外置会话是关掉的(application.yml)").isFalse();
|
||||
|
||||
ReadModels.DetailView view = readService.detail(manager(), SCH, rows.student1Self);
|
||||
assertThat(view.getAnswerId()).isEqualTo(rows.student1Self);
|
||||
assertThat(view.getVersion()).isEqualTo(1);
|
||||
assertThat(view.getValues().path("name").asText()).isEqualTo("张三");
|
||||
assertThat(view.getGroups().toString()).contains("指导教师").contains("\"cert\"");
|
||||
// 对外形状里不出现库内 id(V5):详情响应字段闭集里没有任何 *Id 数字
|
||||
assertThat(view.getFormUuid()).isNotBlank();
|
||||
assertThat(view.getSubmitTime()).isNotBlank();
|
||||
|
||||
// 升版后历史答卷的详情仍是它钉住的 v1 形状(schema 不随升版漂移)
|
||||
fixture.upgradeToV2(rows.formAUuid);
|
||||
ReadModels.DetailView afterUpgrade = readService.detail(manager(), SCH, rows.student1Self);
|
||||
assertThat(afterUpgrade.getVersion()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情越权面:不在范围内=FORBIDDEN(与列表同一构造器判定),别校答卷查不到")
|
||||
void detailIsScopeBound() {
|
||||
ReadFixture fixture = new ReadFixture(this);
|
||||
ReadFixture.Rows rows = fixture.seed(manager());
|
||||
|
||||
assertThatThrownBy(() -> readService.detail(fixture.teacher2(), SCH, rows.teacher1Self))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不在你的数据范围内");
|
||||
assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Self))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
|
||||
// 教师被授权数据管理模块行也改不动别人的行(V39 范围半边:批量逐条判)
|
||||
BoaUser teacher = fixture.teacher1();
|
||||
moduleTree.grantPaths(ReadFixture.TEACHER_1, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
|
||||
batch.setAnswerIds(Arrays.asList(rows.teacher1Self, rows.teacher2Self));
|
||||
batch.setStatus("pending");
|
||||
List<AnswerModels.BatchItemView> results = adminService.batchChangeStatus(teacher, SCH, batch);
|
||||
assertThat(results.get(0).getSuccess()).isTrue();
|
||||
assertThat(results.get(1).getSuccess()).isFalse();
|
||||
assertThat(results.get(1).getMsg()).contains("不在你的数据范围内");
|
||||
}
|
||||
|
||||
// ==================== P25:遮蔽后的钉版溯源 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("P25:平台级表单被本校同 code 表单遮蔽后,历史答卷仍按<b>原 schema</b> 校验/渲染(form_uuid 精确取数)")
|
||||
void platformFormShadowedBySchoolFormStillValidatesItsOwnAnswers() {
|
||||
String code = "t4_shadow";
|
||||
String platformUuid = publishForm(0L, code, "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\","
|
||||
+ "\"fields\":[{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}");
|
||||
BoaUser student = plainUser(3);
|
||||
String answerId = answerService.submit(student, submit(code, "{\"name\":\"平台表单时期填的\"}"))
|
||||
.getAnswerId();
|
||||
Answer pinned = answerRepository.findByUuidAndSchId(answerId, SCH).get();
|
||||
assertThat(pinned.getFormUuid()).isEqualTo(platformUuid);
|
||||
assertThat(pinned.getSchId()).isEqualTo(SCH); // 校域不变式:答卷归属仍是会话校
|
||||
|
||||
// 本校建同 (app_code, code) 表单并发布:遮蔽规则把<b>填报入口</b>指向本校表单
|
||||
String schoolUuid = publishForm(SCH, code, "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\","
|
||||
+ "\"fields\":[{\"key\":\"schoolOnly\",\"label\":\"本校字段\",\"type\":\"text\","
|
||||
+ "\"required\":true}]}]}");
|
||||
assertThat(schoolUuid).isNotEqualTo(platformUuid);
|
||||
|
||||
// 历史答卷按平台 schema 编辑成功(若按本校 schema,必填的 schoolOnly 会把它挡下)
|
||||
AnswerModels.EditRequest edit = new AnswerModels.EditRequest();
|
||||
edit.setAnswerId(answerId);
|
||||
edit.setValues(json("{\"name\":\"管理员按原表修正\"}"));
|
||||
adminService.edit(manager(), SCH, edit);
|
||||
assertThat(answerRepository.findByUuidAndSchId(answerId, SCH).get().getValuesJson())
|
||||
.contains("管理员按原表修正");
|
||||
|
||||
// 反过来:夹带本校表单才有的字段被拒——证明用的是钉住的那张表,不是当前遮蔽后的那张
|
||||
AnswerModels.EditRequest foreign = new AnswerModels.EditRequest();
|
||||
foreign.setAnswerId(answerId);
|
||||
foreign.setValues(json("{\"name\":\"张三\",\"schoolOnly\":\"不该出现\"}"));
|
||||
assertThatThrownBy(() -> adminService.edit(manager(), SCH, foreign))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未知字段");
|
||||
}
|
||||
|
||||
// ==================== 小工具 ====================
|
||||
|
||||
private Set<String> visibleIds(ReadFixture fixture, BoaUser user, String formUuid) {
|
||||
return readService.list(user, SCH, listRequest(formUuid)).getRows().stream()
|
||||
.map(ReadModels.RowView::getAnswerId).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private Set<String> visibleIdsWithFilter(ReadFixture fixture, BoaUser user, String formUuid,
|
||||
ReadModels.FilterItem filter) {
|
||||
ReadModels.ListRequest req = listRequest(formUuid);
|
||||
req.setSize(50);
|
||||
req.setFilters(Arrays.asList(filter));
|
||||
return readService.list(user, SCH, req).getRows().stream()
|
||||
.map(ReadModels.RowView::getAnswerId).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private ReadModels.ListRequest listRequest(String formUuid) {
|
||||
ReadModels.ListRequest req = new ReadModels.ListRequest();
|
||||
req.setFormUuid(formUuid);
|
||||
req.setSize(50);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem filter(String key, String value, String from, String to) {
|
||||
ReadModels.FilterItem item = new ReadModels.FilterItem();
|
||||
item.setKey(key);
|
||||
item.setValue(value);
|
||||
item.setFrom(from);
|
||||
item.setTo(to);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static ReadModels.FilterItem range(String key, String from, String to) {
|
||||
return filter(key, null, from, to);
|
||||
}
|
||||
|
||||
private String scopeName(BoaUser user) {
|
||||
return "user=" + user.getId() + " roles=" + user.getRoleIds();
|
||||
}
|
||||
|
||||
/** 抓 answer 表的 SQL:把 Hibernate SQL 日志临时接到内存 appender(铁律①的可执行取证) */
|
||||
private List<String> captureAnswerSqls(Runnable action) {
|
||||
Logger sqlLogger = (Logger) LoggerFactory.getLogger("org.hibernate.SQL");
|
||||
Level previous = sqlLogger.getLevel();
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
sqlLogger.addAppender(appender);
|
||||
sqlLogger.setLevel(Level.DEBUG);
|
||||
List<String> captured = new ArrayList<>();
|
||||
try {
|
||||
action.run();
|
||||
for (ILoggingEvent event : appender.list) {
|
||||
captured.add(event.getFormattedMessage().toLowerCase());
|
||||
}
|
||||
} finally {
|
||||
sqlLogger.detachAppender(appender);
|
||||
sqlLogger.setLevel(previous);
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ 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 com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -28,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
* 由 {@code AnswerConcurrencyIT} 用双线程打真库取证。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("AnswerService - 提交链路(V17/V31/V32/V33)")
|
||||
class AnswerSubmitIT extends AnswerTestSupport {
|
||||
@@ -84,7 +87,7 @@ class AnswerSubmitIT extends AnswerTestSupport {
|
||||
@DisplayName("V17 正向:有管理权者可代填他人——submitter=操作人、subject=被代填人(两键各司其职,方案 §6.3)")
|
||||
void adminCanFillOnBehalf() {
|
||||
String code = "v17_proxy";
|
||||
publishForm(code, SCHEMA);
|
||||
String formUuid = publishForm(code, SCHEMA);
|
||||
Integer teacherId = 10001;
|
||||
Integer studentId = 20269001;
|
||||
|
||||
@@ -95,6 +98,43 @@ class AnswerSubmitIT extends AnswerTestSupport {
|
||||
Answer stored = answerRepository.findByUuidAndSchId(view.getAnswerId(), SCH).get();
|
||||
assertThat(stored.getSubmitterId()).isEqualTo(teacherId);
|
||||
assertThat(stored.getSubjectId()).isEqualTo(studentId);
|
||||
// P25:钉版坐标的第二维=resolvePublished 命中的那张表单,提交时写死、之后不随升版改变
|
||||
assertThat(stored.getFormUuid()).isEqualTo(formUuid);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("P33:代填的 subject 查无此人或跨校=PARAM_ERROR(存在性与同校一起判;本人自填不查)")
|
||||
void proxySubjectMustExistInSessionSchool() {
|
||||
String code = "p33_subject";
|
||||
publishForm(code, SCHEMA);
|
||||
BoaUser admin = user(10001, SCH, 7);
|
||||
|
||||
Integer missing = 20269999;
|
||||
subjects.markMissing(missing);
|
||||
AnswerModels.SubmitRequest toMissing = submit(code, VALUES);
|
||||
toMissing.setSubjectId(missing);
|
||||
assertThatThrownBy(() -> answerService.submit(admin, toMissing))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("被代填人不存在或不属于本校");
|
||||
|
||||
Integer foreign = 20268888;
|
||||
subjects.markSchool(foreign, OTHER_SCH);
|
||||
AnswerModels.SubmitRequest toForeign = submit(code, VALUES);
|
||||
toForeign.setSubjectId(foreign);
|
||||
assertThatThrownBy(() -> answerService.submit(admin, toForeign))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("被代填人不存在或不属于本校");
|
||||
|
||||
// 正向:同校真实存在的人可代填;=自己(普通用户)不触发 UserBase 查询
|
||||
Integer ok = 20267777;
|
||||
subjects.markSchool(ok, SCH);
|
||||
AnswerModels.SubmitRequest good = submit(code, VALUES);
|
||||
good.setSubjectId(ok);
|
||||
assertThat(answerService.submit(admin, good).getAnswerId()).isNotBlank();
|
||||
|
||||
BoaUser student = user(20266666, SCH, 3);
|
||||
AnswerModels.SubmitRequest self = submit(code, VALUES);
|
||||
self.setSubjectId(20266666);
|
||||
subjects.markMissing(20266666); // 即便登记为"查不到",本人自填也不该去验存在性(会话里的人必然存在)
|
||||
assertThat(answerService.submit(student, self).getAnswerId()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -223,7 +263,7 @@ class AnswerSubmitIT extends AnswerTestSupport {
|
||||
first.setSubjectId(student);
|
||||
AnswerModels.SubmitView view = answerService.submit(user(10001, SCH, 7), first);
|
||||
|
||||
adminService.softDelete(SCH, 10001, view.getAnswerId());
|
||||
adminService.softDelete(manager(10001), SCH, view.getAnswerId());
|
||||
|
||||
AnswerModels.SubmitRequest refill = submit(code, "{\"name\":\"重新填的\",\"grade\":\"g3\"}");
|
||||
refill.setSubjectId(student);
|
||||
|
||||
@@ -6,23 +6,35 @@ 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.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 答卷域测试脚手架(T3 各 IT 共用):会话用户构造 + 建表单并发布 + 提交入参拼装。
|
||||
* 答卷/数据面集成测试脚手架(T3 起各 IT 共用,T4 扩读路径):会话用户构造 + 建表单并发布 + 提交入参拼装
|
||||
* + 两个外部事实源的测试桩。
|
||||
*
|
||||
* <p>身份载体是平台 POJO {@link BoaUser}(真实链路由 CAS 过滤器填充),
|
||||
* 测试直接造它——<b>这正是 V17 的断言方式</b>:服务签名里只有 {@code BoaUser} 与请求体,
|
||||
* 请求体没有 {@code schId/submitterId} 字段,身份只能来自会话对象。</p>
|
||||
*
|
||||
* <p><b>两个外部事实源用桩顶掉</b>({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS):
|
||||
* {@link ModuleTreeSource}(授权模块集=P21 的管理权口径 + customId 范围升级位)与
|
||||
* {@link SubjectDirectory}(P33 的代填存在性)。生产实现是平台签名调用,接线在 T9 联调验;
|
||||
* 本轮取证的是"规则本身对不对",桩把事实源钉住即可。</p>
|
||||
*/
|
||||
abstract class AnswerTestSupport {
|
||||
|
||||
/** 测试专用租户域(避开真实开发库数据;IT 结束一律回滚或显式清理) */
|
||||
protected static final long SCH = 990301L;
|
||||
protected static final long OTHER_SCH = 990302L;
|
||||
/** 管理动作的操作人 id(断言留痕 operator_id 时用它,稳定复现) */
|
||||
protected static final int MANAGER_ID = 10001;
|
||||
|
||||
private static final AtomicInteger USER_SEQ = new AtomicInteger(20000);
|
||||
|
||||
@@ -44,6 +56,18 @@ abstract class AnswerTestSupport {
|
||||
@Autowired
|
||||
protected AnswerIndexService indexService;
|
||||
|
||||
@Autowired
|
||||
protected InMemoryModuleTreeSource moduleTree;
|
||||
|
||||
@Autowired
|
||||
protected InMemorySubjectDirectory subjects;
|
||||
|
||||
@BeforeEach
|
||||
void resetGateways() {
|
||||
moduleTree.reset();
|
||||
subjects.reset();
|
||||
}
|
||||
|
||||
// ==================== 会话用户 ====================
|
||||
|
||||
/** 普通用户(学生 roleId=3 / 教师 roleId=1):无管理权,subject 只能=自己 */
|
||||
@@ -66,6 +90,23 @@ abstract class AnswerTestSupport {
|
||||
return user(USER_SEQ.incrementAndGet(), OTHER_SCH, 7, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员操作人(roleId=7 + 被授权数据管理/定义管理模块行)。
|
||||
*
|
||||
* <p>T4 起管理权看授权模块集(P21 口径),单有 roleId 不再等于能管理——所以"管理员"在测试里
|
||||
* 就是这两件事一起造。需要"管理员但没被授权"的反例时用 {@link #user(Integer, Long, Integer)}
|
||||
* 再 {@code moduleTree.clear(id)}。</p>
|
||||
*/
|
||||
protected BoaUser manager() {
|
||||
return manager(MANAGER_ID);
|
||||
}
|
||||
|
||||
protected BoaUser manager(Integer id) {
|
||||
BoaUser user = user(id, SCH, 7, 2);
|
||||
moduleTree.grantManageDefaults(id);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 指定 id 的会话用户(并发/限次用例需要"同一个人"稳定复现) */
|
||||
protected BoaUser user(Integer id, Long schId, Integer roleId, Integer type) {
|
||||
BoaUser user = new BoaUser();
|
||||
@@ -76,6 +117,10 @@ abstract class AnswerTestSupport {
|
||||
current.setRoleId(roleId);
|
||||
current.setSchId(schId.intValue());
|
||||
user.setCurrentRole(current);
|
||||
if (roleId != null && roleId == 7) {
|
||||
// 管理员在测试语义里=被授权中心模块行的人(手册 §2.2);反例显式 clear
|
||||
moduleTree.grantManageDefaults(id);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -136,4 +181,28 @@ abstract class AnswerTestSupport {
|
||||
throw new IllegalStateException("测试 JSON 写错了:" + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 外部事实源桩 ====================
|
||||
|
||||
/**
|
||||
* IT 上下文里的两个网关桩({@code @Primary} 顶掉平台签名实现)。
|
||||
*
|
||||
* <p>声明在本脚手架的嵌套类里:所有答卷域 IT 都继承它,因此共享同一份上下文配置
|
||||
* (Spring 的嵌套 {@code @TestConfiguration} 会沿测试类继承链发现)。</p>
|
||||
*/
|
||||
@TestConfiguration
|
||||
static class TestGatewayConfiguration {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
InMemoryModuleTreeSource inMemoryModuleTreeSource() {
|
||||
return new InMemoryModuleTreeSource();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
InMemorySubjectDirectory inMemorySubjectDirectory() {
|
||||
return new InMemorySubjectDirectory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
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.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 数据范围解析单测(自验项 <b>V28</b> 全部 + <b>V42</b> 的"身份默认矩阵/customId 升级/回落"三半边)。
|
||||
*
|
||||
* <p>矩阵(契约 §8)在这里逐格断言;"受限身份的统计/导出 ⊆ 列表可见集"那半边是接口级事实,
|
||||
* 在 {@code AnswerReadIT} 打真库断言。桩只顶掉模块树来源(UserBase 事实源),
|
||||
* 解析规则本身全部走生产代码。</p>
|
||||
*/
|
||||
@DisplayName("DataScopeService - 契约 §8 矩阵与 customId 升级(V28/V42)")
|
||||
class DataScopeServiceTest {
|
||||
|
||||
private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode();
|
||||
private static final int TEACHER_FACING = FormEnums.Audience.TEACHER.getCode();
|
||||
|
||||
private final InMemoryModuleTreeSource tree = new InMemoryModuleTreeSource();
|
||||
private final DataScopeService service = new DataScopeService(tree, Arrays.asList(
|
||||
new AllScopeImplementor(), new SelfSubmittedScopeImplementor(),
|
||||
new AboutMeScopeImplementor(), new TeachingClassScopeImplementor()));
|
||||
|
||||
// ==================== V42:身份默认矩阵 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 矩阵:管理员(7)/校领导(2)=全部,两种面向人群都是全部")
|
||||
void adminAndLeaderSeeAll() {
|
||||
for (int roleId : Arrays.asList(7, 2)) {
|
||||
assertThat(service.resolve(user(roleId), form(STUDENT_FACING))).containsExactly(DataScope.ALL);
|
||||
assertThat(service.resolve(user(roleId), form(TEACHER_FACING))).containsExactly(DataScope.ALL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 矩阵:教师(1)×面向学生=一期生效仅本人提交(任教班级实现器缺席);×面向教师=仅本人提交")
|
||||
void teacherScopes() {
|
||||
assertThat(service.resolve(user(1), form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED);
|
||||
assertThat(service.resolve(user(1), form(TEACHER_FACING))).containsExactly(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 矩阵:学生(3)×面向学生=关于我(老师代填的那条也可见);×面向教师按最保守兜底")
|
||||
void studentScopes() {
|
||||
assertThat(service.resolve(user(3), form(STUDENT_FACING))).containsExactly(DataScope.ABOUT_ME);
|
||||
assertThat(service.resolve(user(3), form(TEACHER_FACING))).containsExactly(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 矩阵:家长(4)/工作号(6)/局领导(5)=回落仅本人提交(两种人群都是)")
|
||||
void peripheralRolesFallBackToSelf() {
|
||||
for (int roleId : Arrays.asList(4, 6, 5)) {
|
||||
assertThat(service.resolve(user(roleId), form(STUDENT_FACING)))
|
||||
.as("roleId=" + roleId + " 面向学生")
|
||||
.containsExactly(DataScope.SELF_SUBMITTED);
|
||||
assertThat(service.resolve(user(roleId), form(TEACHER_FACING)))
|
||||
.as("roleId=" + roleId + " 面向教师")
|
||||
.containsExactly(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 全不命中:没有任何 roleIds(异常身份)也落到仅本人提交,不是全部")
|
||||
void noRolesAtAllFallsBackToSelf() {
|
||||
BoaUser nobody = new BoaUser();
|
||||
nobody.setId(20001);
|
||||
nobody.setCurrentRole(role(9, 990301));
|
||||
assertThat(service.resolve(nobody, form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 多来源取最宽=并集:同时有学生与教师身份时,关于我与仅本人提交两格都在(不是二选一)")
|
||||
void multipleIdentitiesUnion() {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(20002);
|
||||
user.setType(0);
|
||||
user.setRoleIds(Arrays.asList(1, 3));
|
||||
user.setCurrentRole(role(1, 990301));
|
||||
Set<DataScope> scopes = service.resolve(user, form(STUDENT_FACING));
|
||||
assertThat(scopes).containsExactlyInAnyOrder(DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME);
|
||||
|
||||
// 其中一格是"全部"时收敛为全部(其余都是它的子集,留着只让读路径多写 OR)
|
||||
user.setRoleIds(Arrays.asList(1, 3, 7));
|
||||
assertThat(service.resolve(user, form(STUDENT_FACING))).containsExactly(DataScope.ALL);
|
||||
}
|
||||
|
||||
// ==================== V42:customId 升级位 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V42 扩展:授权 customId=fc:scope:all 的模块行后,该组成员范围升级为全部")
|
||||
void customIdAnchorUpgradesToAll() {
|
||||
BoaUser teacher = user(1);
|
||||
assertThat(service.resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED);
|
||||
|
||||
tree.grantCustomId(teacher.getId(), DataScopeService.SCOPE_ALL_CUSTOM_ID);
|
||||
assertThat(service.resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.ALL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("customId 扫描是递归的(UserBase 下发的是树,锚点在子层也算命中)且只认 customId 不认 path")
|
||||
void anchorScanIsRecursiveAndExact() {
|
||||
BoaUser teacher = user(1);
|
||||
|
||||
InMemoryModuleTreeSource nested = new InMemoryModuleTreeSource();
|
||||
nested.grantNested(teacher.getId(), "/admin/data",
|
||||
Collections.singletonList(InMemoryModuleTreeSource.child(null,
|
||||
DataScopeService.SCOPE_ALL_CUSTOM_ID)));
|
||||
assertThat(serviceWith(nested).resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.ALL);
|
||||
|
||||
// 只授权了页面路由行(path)不升级范围——第②层管理权与第③层数据范围是两条正交的口径
|
||||
InMemoryModuleTreeSource pathOnly = new InMemoryModuleTreeSource();
|
||||
pathOnly.grantPaths(teacher.getId(), "/admin/data", "/admin/forms");
|
||||
assertThat(serviceWith(pathOnly).resolve(teacher, form(STUDENT_FACING)))
|
||||
.containsExactly(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
|
||||
// ==================== V28:任教班级在枚举里但一期不可选 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("V28:契约 §8 四值都在枚举里;一期可选集合只有三个(任教班级缺席)")
|
||||
void teachingClassIsInEnumButNotSelectable() {
|
||||
assertThat(DataScope.values()).containsExactly(
|
||||
DataScope.ALL, DataScope.TEACHING_CLASS, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME);
|
||||
assertThat(service.selectableScopes()).containsExactlyInAnyOrder(
|
||||
DataScope.ALL, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME);
|
||||
assertThat(service.selectableScopes()).doesNotContain(DataScope.TEACHING_CLASS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V28:任何身份×任何人群都解析不出任教班级(矩阵直接给生效值)")
|
||||
void noCombinationResolvesToTeachingClass() {
|
||||
for (int roleId : new int[]{1, 2, 3, 4, 5, 6, 7}) {
|
||||
for (int audience : new int[]{STUDENT_FACING, TEACHER_FACING}) {
|
||||
assertThat(service.resolve(user(roleId), form(audience)))
|
||||
.as("roleId=" + roleId + " audience=" + audience)
|
||||
.doesNotContain(DataScope.TEACHING_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("V28:任教班级实现器被误用时明确报错(1.5 开放前不存在可用谓词),不会静默按全部放行")
|
||||
void teachingClassImplementorRefusesToBuildPredicate() {
|
||||
assertThatThrownBy(() -> service.implementorOf(DataScope.TEACHING_CLASS)
|
||||
.rowPredicate(null, null, 1))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("一期未开放");
|
||||
}
|
||||
|
||||
// ==================== 其它 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("超管(type==1)恒为全部:它不受单校开通约束(方案 §6.5),平台级行的读侧同口径")
|
||||
void superAdminSeesAll() {
|
||||
BoaUser root = new BoaUser();
|
||||
root.setId(1);
|
||||
root.setType(1);
|
||||
assertThat(service.resolve(root, form(TEACHER_FACING))).containsExactly(DataScope.ALL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未登录不解析范围(拒绝,而不是给一个兜底范围)")
|
||||
void requiresSession() {
|
||||
assertThatThrownBy(() -> service.resolve(null, form(STUDENT_FACING)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
|
||||
assertThatThrownBy(() -> service.resolve(new BoaUser(), form(STUDENT_FACING)))
|
||||
.hasMessageContaining("未登录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("表单为 null(如平台级读上下文缺失)按面向学生列解析,不抛空指针")
|
||||
void nullFormUsesStudentColumn() {
|
||||
assertThat(service.resolve(user(3), null)).containsExactly(DataScope.ABOUT_ME);
|
||||
assertThat(service.resolve(user(1), null)).containsExactly(DataScope.SELF_SUBMITTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("实现器注册表按枚举取用;未注册的范围视为配置缺失直接报错(不静默当全部)")
|
||||
void unknownScopeFailsLoud() {
|
||||
DataScopeService partial = new DataScopeService(tree,
|
||||
new ArrayList<>(Arrays.asList(new AllScopeImplementor())));
|
||||
assertThatThrownBy(() -> partial.implementorOf(DataScope.ABOUT_ME))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("缺少实现器");
|
||||
}
|
||||
|
||||
// ==================== 构造小工具 ====================
|
||||
|
||||
private BoaUser user(int roleId) {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(20000 + roleId);
|
||||
user.setType(0);
|
||||
user.setRoleIds(Collections.singletonList(roleId));
|
||||
user.setCurrentRole(role(roleId, 990301));
|
||||
return user;
|
||||
}
|
||||
|
||||
private BoaRoleInfo role(Integer roleId, int schId) {
|
||||
BoaRoleInfo info = new BoaRoleInfo();
|
||||
info.setRoleId(roleId);
|
||||
info.setSchId(schId);
|
||||
return info;
|
||||
}
|
||||
|
||||
private Form form(int audience) {
|
||||
Form form = new Form();
|
||||
form.setAudience(audience);
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 换一个模块树桩组同一个解析器(实现器集合与生产装配一致:四值四个实现器) */
|
||||
private DataScopeService serviceWith(InMemoryModuleTreeSource source) {
|
||||
return new DataScopeService(source, Arrays.asList(
|
||||
new AllScopeImplementor(), new SelfSubmittedScopeImplementor(),
|
||||
new AboutMeScopeImplementor(), new TeachingClassScopeImplementor()));
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,149 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
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.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 管理权闸门单测(执行手册 §5 <b>P21 裁决</b>的落地断言,也是 V17/V14/V29 的权限层依据)。
|
||||
* 管理权闸门单测(执行手册 §5 <b>P21 裁决的 T4 到期替换</b>:授权模块集口径,roleId 硬编码整体移除)。
|
||||
*
|
||||
* <p>口径:超管 {@code type==1}(UserBase {@code UserTypeEnum.SUPER_ADMIN})可跨校且唯一可管
|
||||
* {@code sch_id=0} 平台级行;校级限 {@code roleId ∈ {2 校领导, 7 管理员}}——
|
||||
* <b>局领导(5) 被明确移出</b>(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。
|
||||
* 这是 T4 换成"授权模块集"口径之前的过渡态。</p>
|
||||
* <p>口径:能进哪个管理面=被授权哪个模块行(手册 §2.2 表:定义管理 {@code /admin/forms}、
|
||||
* 数据管理 {@code /admin/data}、统计 {@code /admin/stats}、字典管理 {@code /admin/dicts});
|
||||
* 超管({@code type==1},UserBase 用户类型不是 roleId)仍可跨校且唯一可管 {@code sch_id=0} 平台级行
|
||||
* (方案 §6.5"超管不受单校开通约束")。</p>
|
||||
*
|
||||
* <p>桩={@link ModuleTreeSource} 的 lambda——本类不启 Spring、不连库,验证的是"给定授权集合,
|
||||
* 判定对不对"。真实树来自 UserBase 的链路在 T9 联调验。</p>
|
||||
*/
|
||||
@DisplayName("FormAdminGuard - P21 过渡闸门")
|
||||
@DisplayName("FormAdminGuard - 授权模块集口径(P21 替换后)")
|
||||
class FormAdminGuardTest {
|
||||
|
||||
private static final long SCH = 880001L;
|
||||
|
||||
private final FormAdminGuard guard = new FormAdminGuard();
|
||||
@Test
|
||||
@DisplayName("被授权数据管理模块行=可进数据面;同 roleId 但未授权=拒(管理权不再来自角色)")
|
||||
void moduleGrantIsTheGate() {
|
||||
FormAdminGuard guard = guardWith("/admin/data");
|
||||
assertThat(guard.requireAdmin(schoolUser(7), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
// 教师被授权了数据管理行同样能进(第②层与第③层正交:能进接口≠能看全部行,行级范围另判)
|
||||
assertThat(guard.requireAdmin(schoolUser(1), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
|
||||
FormAdminGuard none = guardWith();
|
||||
for (int roleId : Arrays.asList(1, 2, 3, 5, 6, 7)) {
|
||||
assertThatThrownBy(() -> none.requireAdmin(schoolUser(roleId), null,
|
||||
FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.as("roleId=" + roleId + " 未授权模块行")
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("无该模块权限");
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
@DisplayName("四个模块行互不通用:只有定义管理权的人调字典面被拒(口径=手册 §2.2 一行一权)")
|
||||
void modulesAreNotInterchangeable() {
|
||||
FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
assertThatCode(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_FORM_DESIGN))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_DICT))
|
||||
.hasMessageContaining(FormAdminGuard.MODULE_DICT);
|
||||
assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_STATS))
|
||||
.hasMessageContaining(FormAdminGuard.MODULE_STATS);
|
||||
}
|
||||
|
||||
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("统计的\"二选一\"配法:授权 /admin/stats 或 /admin/data 任一即可(手册 §2.2 统计可并入数据管理)")
|
||||
void statsAcceptsEitherModuleRow() {
|
||||
assertThat(guardWith(FormAdminGuard.MODULE_STATS)
|
||||
.requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
|
||||
FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE)
|
||||
.requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
|
||||
FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_DICT)
|
||||
.requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
|
||||
FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("需任一");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("path 空的权限锚点(customId=fc:scope:all)不给管理权——它是数据范围升级位,不是模块行")
|
||||
void scopeAnchorIsNotAModuleRow() {
|
||||
FormAdminGuard guard = new FormAdminGuard(user -> {
|
||||
BoaModule anchor = new BoaModule();
|
||||
anchor.setCustomId(DataScopeService.SCOPE_ALL_CUSTOM_ID);
|
||||
anchor.setChildren(new ArrayList<BoaModule>());
|
||||
List<BoaModule> rows = new ArrayList<>();
|
||||
rows.add(anchor);
|
||||
return rows;
|
||||
});
|
||||
assertThatThrownBy(() -> guard.requireAdmin(schoolUser(7), null, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.hasMessageContaining("无该模块权限");
|
||||
assertThat(guard.canManage(schoolUser(7))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有字典管理没有;未授权没有")
|
||||
void canManageFollowsModuleSet() {
|
||||
assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE).canManage(schoolUser(1))).isTrue();
|
||||
assertThat(guardWith(FormAdminGuard.MODULE_FORM_DESIGN).canManage(schoolUser(1))).isTrue();
|
||||
assertThat(guardWith(FormAdminGuard.MODULE_DICT).canManage(schoolUser(7))).isFalse();
|
||||
assertThat(guardWith().canManage(schoolUser(7))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("校级管理员指定他校 schId 被拒(含把 schId 传成 0 想碰平台级行)")
|
||||
void schoolAdminCannotCrossDomain() {
|
||||
FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
BoaUser admin = schoolUser(7);
|
||||
assertThatThrownBy(() -> guard.requireAdmin(admin, 999999L))
|
||||
assertThatThrownBy(() -> guard.requireAdmin(admin, 999999L, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校");
|
||||
assertThatThrownBy(() -> guard.requireAdmin(admin, 0L))
|
||||
assertThatThrownBy(() -> guard.requireAdmin(admin, 0L, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.hasMessageContaining("仅超管可维护");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("超管可显式指定任意域(含 0=平台级),未指定时回落当前身份学校")
|
||||
void superAdminCanTargetAnyDomain() {
|
||||
@DisplayName("超管可显式指定任意域(含 0=平台级),未被授权模块行也能进;无当前身份时必须显式给域")
|
||||
void superAdminBypassesModuleRows() {
|
||||
FormAdminGuard guard = guardWith();
|
||||
BoaUser root = new BoaUser();
|
||||
root.setId(1);
|
||||
root.setType(1);
|
||||
assertThat(guard.requireAdmin(root, 0L)).isEqualTo(0L);
|
||||
assertThat(guard.requireAdmin(root, SCH)).isEqualTo(SCH);
|
||||
assertThat(guard.requireAdmin(root, 0L, FormAdminGuard.MODULE_DICT)).isEqualTo(0L);
|
||||
assertThat(guard.requireAdmin(root, SCH, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
root.setCurrentRole(role(3, (int) SCH));
|
||||
assertThat(guard.requireAdmin(root, null)).isEqualTo(SCH);
|
||||
assertThat(guard.requireAdmin(root, null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
|
||||
assertThat(guard.canManage(root)).isTrue();
|
||||
|
||||
BoaUser rootWithoutSchool = new BoaUser();
|
||||
rootWithoutSchool.setId(1);
|
||||
rootWithoutSchool.setType(1);
|
||||
assertThatThrownBy(() -> guard.requireAdmin(rootWithoutSchool, null))
|
||||
.hasMessageContaining("超管请显式指定 schId");
|
||||
assertThatThrownBy(() -> guard.requireAdmin(rootWithoutSchool, null,
|
||||
FormAdminGuard.MODULE_DATA_MANAGE)).hasMessageContaining("超管请显式指定 schId");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无会话=UNAUTHORIZED(不是 FORBIDDEN)——契约 §6 定稿映射的未登录段")
|
||||
void missingSessionIsUnauthorized() {
|
||||
assertThatThrownBy(() -> guard.requireAdmin(null, null))
|
||||
FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
assertThatThrownBy(() -> guard.requireAdmin(null, null, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未登录");
|
||||
BoaUser anonymous = new BoaUser();
|
||||
assertThatThrownBy(() -> guard.requireAdmin(anonymous, null)).hasMessageContaining("未登录");
|
||||
assertThatThrownBy(() -> guard.requireAdmin(anonymous, null, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.hasMessageContaining("未登录");
|
||||
assertThatThrownBy(() -> guard.requireAdminAny(anonymous, null, FormAdminGuard.MODULE_STATS))
|
||||
.hasMessageContaining("未登录");
|
||||
assertThat(guard.canManage(null)).isFalse();
|
||||
assertThat(guard.canManage(anonymous)).isFalse();
|
||||
}
|
||||
@@ -86,15 +151,32 @@ class FormAdminGuardTest {
|
||||
@Test
|
||||
@DisplayName("有当前身份但无学校归属(区域组/根直属)不算管理权——不给出「本校」域")
|
||||
void noSchoolBindingIsNotAdmin() {
|
||||
FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
BoaUser user = new BoaUser();
|
||||
user.setId(10133);
|
||||
user.setRoleIds(Collections.singletonList(7));
|
||||
user.setCurrentRole(role(7, null));
|
||||
assertThatThrownBy(() -> guard.requireAdmin(user, null)).hasMessageContaining("无表单管理权限");
|
||||
assertThatThrownBy(() -> guard.requireAdmin(user, null, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.hasMessageContaining("无学校归属");
|
||||
assertThat(guard.canManage(user)).isFalse();
|
||||
|
||||
BoaUser full = schoolUser(7);
|
||||
assertThatCode(() -> guard.requireAdmin(full, SCH)).doesNotThrowAnyException();
|
||||
assertThatCode(() -> guard.requireAdmin(full, SCH, FormAdminGuard.MODULE_DATA_MANAGE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
/** 授权集合直给的守卫(模块行 path 变参;path 空行不在此列,见锚点用例) */
|
||||
private static FormAdminGuard guardWith(String... paths) {
|
||||
return new FormAdminGuard(user -> {
|
||||
List<BoaModule> rows = new ArrayList<>();
|
||||
for (String path : paths) {
|
||||
BoaModule module = new BoaModule();
|
||||
module.setPath(path);
|
||||
module.setChildren(new ArrayList<BoaModule>());
|
||||
rows.add(module);
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
|
||||
private BoaUser schoolUser(int roleId) {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.context.annotation.Import;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -34,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
* 即"未注入平台 env 时读 jar 内本地键"这条真实分支——测试顺手把这条读取形态钉住了。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@TestPropertySource(properties = "file.upload-path=target/tmp-upload")
|
||||
@DisplayName("FormFileService - 上传模块(V24 本地部分)")
|
||||
@@ -179,6 +181,18 @@ class FormFileServiceIT extends AnswerTestSupport {
|
||||
"{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"cert.jpg\","
|
||||
+ "\"key\":\"http://cdn.example/cert.jpg\"}]}")))
|
||||
.hasMessageContaining("相对存储路径");
|
||||
|
||||
// P34:id 是真的、key 却指向别的相对路径——挂得住 id 也挂不住语义,仍拒(跨租户渲染的前置条件之一)
|
||||
assertThatThrownBy(() -> answerService.submit(user(10001, SCH, 3), submit(code,
|
||||
"{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"cert.jpg\","
|
||||
+ "\"key\":\"form/2026/10/someone-else.jpg\"}]}")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("附件 key 与上传记录不一致");
|
||||
|
||||
// P34:fileName 顺带比对(合法前端原样转存返回值,这里不改坏正常流程)
|
||||
assertThatThrownBy(() -> answerService.submit(user(10001, SCH, 3), submit(code,
|
||||
"{\"name\":\"张三\",\"cert\":[{\"id\":" + realId + ",\"fileName\":\"改名.jpg\","
|
||||
+ "\"key\":\"" + uploaded.getKey() + "\"}]}")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("附件文件名与上传记录不一致");
|
||||
}
|
||||
|
||||
// ==================== 小工具 ====================
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的授权模块集口径
|
||||
* 与契约 §8 的 customId 扫描都注入这里取证,真实链路 T9 联调验)。
|
||||
*
|
||||
* <p>建模的是 UserBase 侧的两种模块行(手册 §2.2):{@code path} 非空=页面路由行、
|
||||
* {@code path} 空 + {@code customId}=纯权限锚点。缺省<em>不给任何模块</em>——与生产侧
|
||||
* fail-closed 同向(无授权=无管理权、范围回落仅本人提交),需要权限的用例显式授权。</p>
|
||||
*/
|
||||
public class InMemoryModuleTreeSource implements ModuleTreeSource {
|
||||
|
||||
private final Map<Integer, List<BoaModule>> byUser = new ConcurrentHashMap<>();
|
||||
|
||||
/** 授权页面路由模块行(path 逐字用手册 §2.2 的那几条) */
|
||||
public void grantPaths(Integer userId, String... paths) {
|
||||
List<BoaModule> rows = rows(userId);
|
||||
for (String path : paths) {
|
||||
rows.add(row(path, null));
|
||||
}
|
||||
}
|
||||
|
||||
/** 授权 path 空的权限锚点(数据范围升级位 {@code fc:scope:all} 就是这种形态) */
|
||||
public void grantCustomId(Integer userId, String customId) {
|
||||
rows(userId).add(row(null, customId));
|
||||
}
|
||||
|
||||
/** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */
|
||||
public void clear(Integer userId) {
|
||||
byUser.remove(userId);
|
||||
}
|
||||
|
||||
/** 每条用例前清空全部授权(脚手架 @BeforeEach 调它,防用例间串权限) */
|
||||
public void reset() {
|
||||
byUser.clear();
|
||||
}
|
||||
|
||||
public List<String> pathsOf(Integer userId) {
|
||||
List<String> paths = new ArrayList<>();
|
||||
for (BoaModule module : byUser.getOrDefault(userId, Collections.<BoaModule>emptyList())) {
|
||||
if (module.getPath() != null) {
|
||||
paths.add(module.getPath());
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaModule> authorizedModules(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 桩返回扁平列表即可:FormModuleKeys 的递归遍历对 children=null 与树形都同样处理
|
||||
return Collections.unmodifiableList(
|
||||
new ArrayList<>(byUser.getOrDefault(user.getId(), Collections.<BoaModule>emptyList())));
|
||||
}
|
||||
|
||||
private List<BoaModule> rows(Integer userId) {
|
||||
return byUser.computeIfAbsent(userId, key -> Collections.synchronizedList(new ArrayList<>()));
|
||||
}
|
||||
|
||||
private static BoaModule row(String path, String customId) {
|
||||
BoaModule module = new BoaModule();
|
||||
module.setPath(path);
|
||||
module.setCustomId(customId);
|
||||
module.setName(path == null ? "权限锚点 " + customId : "模块 " + path);
|
||||
module.setChildren(new ArrayList<BoaModule>());
|
||||
return module;
|
||||
}
|
||||
|
||||
/** 便于用例一行授权"数据管理 + 定义管理"(=手册 §2.2 的中心管理员常见配法) */
|
||||
public void grantManageDefaults(Integer userId) {
|
||||
grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
}
|
||||
|
||||
/** 树形下发形态(有子节点):验证扫描是递归的,不只读顶层 */
|
||||
public void grantNested(Integer userId, String parentPath, List<BoaModule> children) {
|
||||
BoaModule parent = row(parentPath, null);
|
||||
parent.setChildren(new ArrayList<>(children));
|
||||
rows(userId).add(parent);
|
||||
}
|
||||
|
||||
public static BoaModule child(String path, String customId) {
|
||||
return row(path, customId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* {@link SubjectDirectory} 的测试桩(P33:本地 IT 不启动 UserBase,代填存在性/同校判定注入这里取证)。
|
||||
*
|
||||
* <p>缺省口径=「UserBase 正常返回该用户且与查询者同校」,因为绝大多数用例关心的是别的规则;
|
||||
* 需要反例时用 {@link #markMissing} / {@link #markSchool} 显式登记,
|
||||
* 这样 P33 的两条拒绝分支(查不到、跨校)都有独立断言。</p>
|
||||
*/
|
||||
public class InMemorySubjectDirectory implements SubjectDirectory {
|
||||
|
||||
private final Set<Integer> missing = Collections.newSetFromMap(new ConcurrentHashMap<Integer, Boolean>());
|
||||
private final Map<Integer, Long> schoolOf = new ConcurrentHashMap<>();
|
||||
|
||||
/** 登记"查无此人" */
|
||||
public void markMissing(Integer subjectId) {
|
||||
missing.add(subjectId);
|
||||
}
|
||||
|
||||
/** 登记该 id 的真实归属校(跨校反例用) */
|
||||
public void markSchool(Integer subjectId, Long schId) {
|
||||
schoolOf.put(subjectId, schId);
|
||||
missing.remove(subjectId);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
missing.clear();
|
||||
schoolOf.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsInSchool(Integer subjectId, Long schId) {
|
||||
if (subjectId == null || schId == null) {
|
||||
return false;
|
||||
}
|
||||
if (missing.contains(subjectId)) {
|
||||
return false;
|
||||
}
|
||||
Long owner = schoolOf.get(subjectId);
|
||||
return owner == null || owner.equals(schId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.formcenter.model.FormModels;
|
||||
import com.yunhai.formcenter.model.OapiModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 对外读接口集成测试(契约 §9 两端点 + §4 下发形状;真实 MySQL)。
|
||||
*
|
||||
* <p>断言的四件事:</p>
|
||||
* <ul>
|
||||
* <li><b>自包含</b>:字典引用在出参里已内联展开成 {@code options:[{label,value}]},
|
||||
* 消费方不需要再请求字典(契约 §2.1);</li>
|
||||
* <li><b>机读值形状</b>:每个字段带 {@code valueSchema}({@code FormValueSchemaBuilderTest} 已逐类断言形状,
|
||||
* 这里断言它真的出现在下发链路里);</li>
|
||||
* <li><b>遮蔽规则与填报入口同源</b>:{@code /oapi/form/schema} 走的就是 {@code resolvePublished},
|
||||
* 本校表单建好后同一请求指向本校行(契约 §1);</li>
|
||||
* <li><b>答卷给的是钉住版本</b>:升版后读历史答卷,schema 仍是它提交时那张表(契约 §9"快照"语义的前提)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>信封 {@code code=0} 与验签墙是平台链({@code /oapi/*} 401 由 {@code local-debug/t4-read-smoke.sh}
|
||||
* 启动冒烟取证),本类断言的是 data 载荷内容。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
|
||||
@Transactional
|
||||
@DisplayName("对外读接口 - /oapi/form/schema 与 /answer(契约 §4/§9)")
|
||||
class OapiFormServiceIT extends AnswerTestSupport {
|
||||
|
||||
private static final String CODE = "t4_oapi_honor";
|
||||
|
||||
/** grade 走字典引用(内联展开的取证点),score/cert 走 valueSchema 的 number/附件类 */
|
||||
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
|
||||
+ "\"options\":{\"source\":\"dict:grades\"}},"
|
||||
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\"},"
|
||||
+ "{\"key\":\"cert\",\"label\":\"证书\",\"type\":\"image\",\"maxFiles\":3}"
|
||||
+ "]}]}";
|
||||
|
||||
/** v2:多一个"新字段",用于证明历史答卷给的仍是 v1 的表 */
|
||||
private static final String SCHEMA_V2 = SCHEMA.replace(
|
||||
"{\"key\":\"cert\"", "{\"key\":\"extra\",\"label\":\"新字段\",\"type\":\"text\"},{\"key\":\"cert\"");
|
||||
|
||||
@Autowired
|
||||
private OapiFormService oapiFormService;
|
||||
|
||||
@Test
|
||||
@DisplayName("schema 下发:字典已内联成 options、每字段带 valueSchema、audience 给枚举名、无库内 id")
|
||||
void schemaIsSelfContainedWithInlineOptions() {
|
||||
String formUuid = publishForm(CODE, SCHEMA);
|
||||
|
||||
OapiModels.SchemaView view = oapiFormService.schema(SCH, null, CODE);
|
||||
assertThat(view.getFormUuid()).isEqualTo(formUuid);
|
||||
assertThat(view.getVersion()).isEqualTo(1);
|
||||
assertThat(view.getCode()).isEqualTo(CODE);
|
||||
assertThat(view.getAppCode()).isEqualTo("form-center");
|
||||
assertThat(view.getAudience()).isEqualTo("STUDENT");
|
||||
|
||||
JsonNode grade = field(view.getGroups(), "grade");
|
||||
assertThat(grade.path("options").isArray()).isTrue();
|
||||
assertThat(grade.path("options")).extracting(node -> node.path("value").asText())
|
||||
.containsExactly("g2", "g3");
|
||||
assertThat(grade.path("options").get(0).path("label").asText()).isEqualTo("高二年级");
|
||||
assertThat(grade.path("valueSchema").path("enum")).extracting(JsonNode::asText)
|
||||
.containsExactly("g2", "g3");
|
||||
assertThat(field(view.getGroups(), "score").path("valueSchema").path("type").asText())
|
||||
.isEqualTo("number");
|
||||
assertThat(field(view.getGroups(), "cert").path("valueSchema").path("items")
|
||||
.path("additionalProperties").asBoolean()).isFalse();
|
||||
// 字典本身不出现在出参里(引用键已被替换)
|
||||
assertThat(view.getGroups().toString()).doesNotContain("dict:grades");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("schema 的 resolve 语义:本校同 code 表单遮蔽平台级;他校专属 code 在别校查不到")
|
||||
void schemaFollowsShadowingAndDomainRules() {
|
||||
String platformUuid = publishForm(0L, CODE, SCHEMA); // 平台级表单
|
||||
// 域=会话校 ∪ 平台级:别校也能解析到平台级行(契约 §1,这不是越权——平台级本就共享)
|
||||
assertThat(oapiFormService.schema(SCH, null, CODE).getFormUuid()).isEqualTo(platformUuid);
|
||||
assertThat(oapiFormService.schema(OTHER_SCH, null, CODE).getFormUuid()).isEqualTo(platformUuid);
|
||||
|
||||
String schoolUuid = publishForm(SCH, CODE, SCHEMA); // 本校同 code 表单:遮蔽生效
|
||||
assertThat(oapiFormService.schema(SCH, null, CODE).getFormUuid()).isEqualTo(schoolUuid);
|
||||
// 遮蔽不穿透:他校请求同 (app_code, code) 仍只命中平台级那一行,不会被指到本校表单
|
||||
assertThat(oapiFormService.schema(OTHER_SCH, null, CODE).getFormUuid()).isEqualTo(platformUuid);
|
||||
|
||||
// 纯校级表单(无平台级同 code 行)在别校域里查不到
|
||||
String schoolOnlyCode = "t4_oapi_school_only";
|
||||
publishForm(SCH, schoolOnlyCode, SCHEMA);
|
||||
assertThatThrownBy(() -> oapiFormService.schema(OTHER_SCH, null, schoolOnlyCode))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("表单不存在");
|
||||
|
||||
assertThatThrownBy(() -> oapiFormService.schema(null, null, CODE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("schId");
|
||||
assertThatThrownBy(() -> oapiFormService.schema(SCH, "other-app", CODE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("表单不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("answer 下发:给的是<b>钉住版本</b>的 schema + 值文档;升版后历史答卷的形状不漂移(契约 §9 快照语义)")
|
||||
void answerCarriesItsPinnedVersionSchema() {
|
||||
String formUuid = publishForm(CODE, SCHEMA);
|
||||
String answerId = answerService.submit(plainUser(3),
|
||||
submit(CODE, "{\"grade\":\"g2\",\"score\":95.5}")).getAnswerId();
|
||||
|
||||
// 升版(新字段 extra)
|
||||
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
|
||||
save.setUuid(formUuid);
|
||||
save.setSchemaJson(SCHEMA_V2);
|
||||
defService.save(SCH, save);
|
||||
defService.publish(SCH, formUuid, "加新字段");
|
||||
|
||||
OapiModels.AnswerView view = oapiFormService.answer(SCH, answerId);
|
||||
assertThat(view.getAnswerId()).isEqualTo(answerId);
|
||||
assertThat(view.getVersion()).isEqualTo(1);
|
||||
assertThat(view.getFormUuid()).isEqualTo(formUuid);
|
||||
assertThat(view.getSchemaRef().getVersion()).isEqualTo(1);
|
||||
assertThat(view.getValues().path("score").asDouble()).isEqualTo(95.5);
|
||||
assertThat(view.getStatus()).isEqualTo("pending");
|
||||
assertThat(view.getSubmitTime()).isNotBlank();
|
||||
assertThat(view.getGroups().toString()).doesNotContain("新字段"); // 给的是 v1 那张表
|
||||
assertThat(field(view.getGroups(), "grade").path("options").isArray()).isTrue();
|
||||
|
||||
// 软删与跨校都读不到(与四条 CAS 读路径同口径)
|
||||
adminService.softDelete(manager(), SCH, answerId);
|
||||
assertThatThrownBy(() -> oapiFormService.answer(SCH, answerId))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("answer 的租户域由调用方声明:别的 schId 读不到本校答卷(不给跨校探测面)")
|
||||
void answerIsDomainBound() {
|
||||
publishForm(CODE, SCHEMA);
|
||||
String answerId = answerService.submit(plainUser(3), submit(CODE, "{\"grade\":\"g2\"}")).getAnswerId();
|
||||
assertThatThrownBy(() -> oapiFormService.answer(OTHER_SCH, answerId))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
|
||||
assertThatThrownBy(() -> oapiFormService.answer(SCH, null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("answerId 不能为空");
|
||||
}
|
||||
|
||||
// ==================== 小工具 ====================
|
||||
|
||||
/** 建字典(grades:高二年级=g2 / 高三年级=g3)+ 表单 + 发布,返回表单 uuid */
|
||||
@Override
|
||||
protected String publishForm(String code, String schemaJson) {
|
||||
return publishForm(SCH, code, schemaJson);
|
||||
}
|
||||
|
||||
/** 平台级表单(sch_id=0)也要能在本类的字典引用下发布,故两个重载都先备字典 */
|
||||
@Override
|
||||
protected String publishForm(Long schId, String code, String schemaJson) {
|
||||
ensureDict(schId);
|
||||
return super.publishForm(schId, code, schemaJson);
|
||||
}
|
||||
|
||||
/** 本级字典(服务层方法不带守卫,测试直接建 0 级/校级的行) */
|
||||
private void ensureDict(Long schId) {
|
||||
dictService.saveDict(schId, "grades", "年级");
|
||||
dictService.saveItem(schId, "grades", "g2", "高二年级", 0);
|
||||
dictService.saveItem(schId, "grades", "g3", "高三年级", 1);
|
||||
}
|
||||
|
||||
private static JsonNode field(JsonNode groups, String key) {
|
||||
for (JsonNode group : groups) {
|
||||
for (JsonNode field : group.path("fields")) {
|
||||
if (key.equals(field.path("key").asText())) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("下发 schema 里没有字段 " + key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 代填对象的学校归属判定单测(P33 的判定半边——存在性那半边在 IT 里用桩顶掉 UserBase 调用)。
|
||||
*
|
||||
* <p>判据取 UserBase {@code /oapi/user/byId} 下发的三层字段:{@code schools}(全部可选身份的学校,
|
||||
* 本期起填充)为主,{@code school}(当前激活学校)与 {@code currentRole.schId} 为兜底——
|
||||
* 三层任一含会话校即算同校。之所以不只看 {@code school}:多校身份的人当前激活校是"切到哪所"的运行时状态,
|
||||
* 用它判同校会把合法的第二身份代填误拒。</p>
|
||||
*/
|
||||
@DisplayName("PlatformSubjectDirectory - 同校归属判定(P33)")
|
||||
class PlatformSubjectDirectoryTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("schools 含目标校=同校;不含则再看 school/currentRole")
|
||||
void checksAllThreeAffiliationFields() {
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(withSchools(7L, 990301L), 990301L)).isTrue();
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(withSchools(7L, 123L), 990301L)).isFalse();
|
||||
|
||||
BoaUser currentSchoolOnly = new BoaUser();
|
||||
currentSchoolOnly.setSchool(group(990301));
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(currentSchoolOnly, 990301L)).isTrue();
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(currentSchoolOnly, 990302L)).isFalse();
|
||||
|
||||
BoaUser roleOnly = new BoaUser();
|
||||
BoaRoleInfo role = new BoaRoleInfo();
|
||||
role.setSchId(990301);
|
||||
roleOnly.setCurrentRole(role);
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(roleOnly, 990301L)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空对象/空域一律按\"不属于本校\"处理(fail-closed:查不到就拒,不给代填开门)")
|
||||
void nullsAreNotSameSchool() {
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(null, 990301L)).isFalse();
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(new BoaUser(), 990301L)).isFalse();
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(withSchools(1L), null)).isFalse();
|
||||
BoaUser emptyLists = new BoaUser();
|
||||
emptyLists.setSchools(Collections.<BoaGroup>emptyList());
|
||||
assertThat(PlatformSubjectDirectory.belongsToSchool(emptyLists, 990301L)).isFalse();
|
||||
}
|
||||
|
||||
private BoaUser withSchools(long... schoolIds) {
|
||||
BoaUser user = new BoaUser();
|
||||
user.setSchools(Arrays.asList(groups(schoolIds)));
|
||||
return user;
|
||||
}
|
||||
|
||||
private BoaGroup[] groups(long... schoolIds) {
|
||||
BoaGroup[] array = new BoaGroup[schoolIds.length];
|
||||
for (int i = 0; i < schoolIds.length; i++) {
|
||||
array[i] = group((int) schoolIds[i]);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private BoaGroup group(Integer id) {
|
||||
BoaGroup group = new BoaGroup();
|
||||
group.setId(id);
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.model.AnswerModels;
|
||||
import com.yunhai.formcenter.model.FormModels;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
/**
|
||||
* 读路径 IT 的数据集脚手架(T4):两个表单(面向学生/面向教师)× 多身份 × 软删混合的答卷,
|
||||
* 供 {@code AnswerReadIT}(列表/详情/统计/范围)与 {@code AnswerExportIT}(V37)共用同一份事实,
|
||||
* 从而能<em>逐身份比对</em>"统计/导出 ⊆ 列表可见集"(V27/V42 的硬断言)。
|
||||
*
|
||||
* <p>数据集刻意包含的形状:</p>
|
||||
* <ul>
|
||||
* <li>{@code advisor} 字段在 v1 schema 里存在但<b>不声明 searchable</b>,v2 才声明——
|
||||
* V9(升版声明 searchable 后旧答卷可按新字段筛,靠发布时补建索引行)用的就是它;</li>
|
||||
* <li>{@code cert} 是 upload 且被勾了 searchable——P28(无检索算子:不写索引行、不进筛选候选);</li>
|
||||
* <li>学生自填的行 {@code subject_id} 为空、管理员代填的行 {@code subject_id}=学生——
|
||||
* 契约 §8"学生=关于我"与"仅本人提交"两格的差异只有这种数据才测得出来;</li>
|
||||
* <li>一条被软删的行——四条读出路径都必须看不见(V30 的读侧半边)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
class ReadFixture {
|
||||
|
||||
/** 表单 A:面向学生(矩阵走"面向学生"那一列) */
|
||||
static final String FORM_A = "t4_read_a";
|
||||
/** 表单 B:面向教师(矩阵走"面向教师"那一列) */
|
||||
static final String FORM_B = "t4_read_b";
|
||||
|
||||
static final String SCHEMA_V1 = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
|
||||
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]},"
|
||||
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"searchable\":true},"
|
||||
+ "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true},"
|
||||
+ "{\"key\":\"advisor\",\"label\":\"指导教师\",\"type\":\"text\"},"
|
||||
+ "{\"key\":\"cert\",\"label\":\"证书照片\",\"type\":\"upload\",\"searchable\":true,"
|
||||
+ "\"accept\":\".jpg,.png\",\"maxFiles\":9}"
|
||||
+ "]}]}";
|
||||
|
||||
/** 升版:advisor 声明 searchable(V9 的用例面),其余逐字不变 */
|
||||
static final String SCHEMA_V2 = SCHEMA_V1.replace(
|
||||
"{\"key\":\"advisor\",\"label\":\"指导教师\",\"type\":\"text\"}",
|
||||
"{\"key\":\"advisor\",\"label\":\"指导教师\",\"type\":\"text\",\"searchable\":true}");
|
||||
|
||||
static final String SCHEMA_TEACHER = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
|
||||
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"searchable\":true,"
|
||||
+ "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}"
|
||||
+ "]}]}";
|
||||
|
||||
/** 稳定 id:代填与范围断言要反复引用同一个人 */
|
||||
static final int STUDENT_1 = 20264001;
|
||||
static final int STUDENT_2 = 20264002;
|
||||
static final int TEACHER_1 = 20264101;
|
||||
static final int TEACHER_2 = 20264102;
|
||||
static final int BUREAU_LEADER = 20264201;
|
||||
|
||||
private final AnswerTestSupport support;
|
||||
|
||||
ReadFixture(AnswerTestSupport support) {
|
||||
this.support = support;
|
||||
}
|
||||
|
||||
/** 答卷 uuid 集(按写入顺序命名,断言直接引用) */
|
||||
static final class Rows {
|
||||
/** 学生1 自填:grade g2 / score 90 / advisor 李老师 */
|
||||
String student1Self;
|
||||
/** 学生2 自填:grade g3 / score 80 / advisor 王老师 */
|
||||
String student2Self;
|
||||
/** 管理员代填学生1:grade g2 / score 70 / advisor 李老师(subject=学生1、submitter=管理员) */
|
||||
String proxyForStudent1;
|
||||
/** 教师1 自填:grade g3 / score 95 / advisor 张老师 */
|
||||
String teacher1Self;
|
||||
/** 教师2 自填:grade g2 / score 60 / advisor 张老师 */
|
||||
String teacher2Self;
|
||||
/** 学生2 自填后被软删(四条读路径都看不见) */
|
||||
String softDeleted;
|
||||
/** 表单 B(面向教师)上教师1 自填的一条 */
|
||||
String teacherFacingRow;
|
||||
/** 表单 A 的 uuid(列表/统计/导出的入参,读路径按表单 uuid 寻址) */
|
||||
String formAUuid;
|
||||
/** 表单 B 的 uuid */
|
||||
String formBUuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建 A/B 两表单(B 配成面向教师)并造出上述答卷,返回 uuid 集。
|
||||
*
|
||||
* @param admin 有数据管理权且有学校归属的操作人(代填与软删用它)
|
||||
*/
|
||||
Rows seed(BoaUser admin) {
|
||||
Rows rows = new Rows();
|
||||
rows.formAUuid = support.publishForm(FORM_A, SCHEMA_V1);
|
||||
rows.formBUuid = support.publishForm(FORM_B, SCHEMA_TEACHER);
|
||||
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
|
||||
config.setAudience(2);
|
||||
support.config(AnswerTestSupport.SCH, rows.formBUuid, config);
|
||||
|
||||
rows.student1Self = submit(student1(), FORM_A,
|
||||
"{\"name\":\"张三\",\"grade\":\"g2\",\"score\":90,\"birthday\":\"2010-03-09\","
|
||||
+ "\"advisor\":\"李老师\"}");
|
||||
rows.student2Self = submit(student2(), FORM_A,
|
||||
"{\"name\":\"李四\",\"grade\":\"g3\",\"score\":80,\"birthday\":\"2009-11-02\","
|
||||
+ "\"advisor\":\"王老师\"}");
|
||||
rows.proxyForStudent1 = submitProxy(admin, FORM_A, STUDENT_1,
|
||||
"{\"name\":\"张三(老师代填)\",\"grade\":\"g2\",\"score\":70,\"birthday\":\"2010-03-09\","
|
||||
+ "\"advisor\":\"李老师\"}");
|
||||
rows.teacher1Self = submit(teacher1(), FORM_A,
|
||||
"{\"name\":\"王五\",\"grade\":\"g3\",\"score\":95,\"birthday\":\"2009-05-20\","
|
||||
+ "\"advisor\":\"张老师\"}");
|
||||
rows.teacher2Self = submit(teacher2(), FORM_A,
|
||||
"{\"name\":\"赵六\",\"grade\":\"g2\",\"score\":60,\"birthday\":\"2010-01-15\","
|
||||
+ "\"advisor\":\"张老师\"}");
|
||||
rows.softDeleted = submit(student2(), FORM_A,
|
||||
"{\"name\":\"被删的行\",\"grade\":\"g3\",\"score\":10,\"birthday\":\"2009-01-01\","
|
||||
+ "\"advisor\":\"李老师\"}");
|
||||
support.adminService.softDelete(admin, AnswerTestSupport.SCH, rows.softDeleted);
|
||||
rows.teacherFacingRow = submit(teacher1(), FORM_B, "{\"name\":\"教师问卷\",\"grade\":\"g2\"}");
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 升版 v2(advisor 声明 searchable)——发布动作内含索引补建(V9) */
|
||||
void upgradeToV2(String formUuid) {
|
||||
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
|
||||
save.setUuid(formUuid);
|
||||
save.setSchemaJson(SCHEMA_V2);
|
||||
support.defService.save(AnswerTestSupport.SCH, save);
|
||||
support.defService.publish(AnswerTestSupport.SCH, formUuid, "指导教师改为可筛");
|
||||
}
|
||||
|
||||
String submit(BoaUser user, String code, String valuesJson) {
|
||||
return support.answerService.submit(user, support.submit(code, valuesJson)).getAnswerId();
|
||||
}
|
||||
|
||||
String submitProxy(BoaUser admin, String code, int subjectId, String valuesJson) {
|
||||
AnswerModels.SubmitRequest req = support.submit(code, valuesJson);
|
||||
req.setSubjectId(subjectId);
|
||||
return support.answerService.submit(admin, req).getAnswerId();
|
||||
}
|
||||
|
||||
BoaUser student1() {
|
||||
return support.user(STUDENT_1, AnswerTestSupport.SCH, 3);
|
||||
}
|
||||
|
||||
BoaUser student2() {
|
||||
return support.user(STUDENT_2, AnswerTestSupport.SCH, 3);
|
||||
}
|
||||
|
||||
BoaUser teacher1() {
|
||||
return support.user(TEACHER_1, AnswerTestSupport.SCH, 1);
|
||||
}
|
||||
|
||||
BoaUser teacher2() {
|
||||
return support.user(TEACHER_2, AnswerTestSupport.SCH, 1);
|
||||
}
|
||||
|
||||
BoaUser bureauLeader() {
|
||||
return support.user(BUREAU_LEADER, AnswerTestSupport.SCH, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 测试侧读回导出 xlsx 的工具(V37 的取证要按"文件里到底写了什么"断言,不能只看服务返回值)。
|
||||
*
|
||||
* <p>用 easyexcel 自己的同步读:无模型类时返回 {@code List<Map<列号, 单元格文本>>};
|
||||
* {@code headRowNumber(0)} 表示表头也当数据行读回来(列顺序与表头文案的断言用)。</p>
|
||||
*/
|
||||
final class XlsxReader {
|
||||
|
||||
private XlsxReader() {
|
||||
}
|
||||
|
||||
/** 全部行(第 0 行是表头) */
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<Map<Integer, String>> allRows(byte[] xlsx) {
|
||||
List<?> raw = EasyExcel.read(new ByteArrayInputStream(xlsx)).sheet(0).headRowNumber(0).doReadSync();
|
||||
List<Map<Integer, String>> rows = new ArrayList<>();
|
||||
for (Object row : raw) {
|
||||
rows.add((Map<Integer, String>) row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 数据行(去掉表头行) */
|
||||
static List<Map<Integer, String>> dataRows(byte[] xlsx) {
|
||||
List<Map<Integer, String>> all = allRows(xlsx);
|
||||
return all.isEmpty() ? all : new ArrayList<>(all.subList(1, all.size()));
|
||||
}
|
||||
|
||||
/** 第一列(答卷ID uuid)集合——"导出 ⊆ 列表可见集"的断言载体 */
|
||||
static List<String> firstColumn(byte[] xlsx) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Map<Integer, String> row : dataRows(xlsx)) {
|
||||
values.add(row.get(0));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 表头文案(按列序) */
|
||||
static List<String> headers(byte[] xlsx) {
|
||||
List<Map<Integer, String>> all = allRows(xlsx);
|
||||
List<String> titles = new ArrayList<>();
|
||||
if (all.isEmpty()) {
|
||||
return titles;
|
||||
}
|
||||
Map<Integer, String> header = all.get(0);
|
||||
for (int column = 0; column < header.size(); column++) {
|
||||
titles.add(header.get(column));
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user