From 49b095eb812a262352e41a2f08a23e0fe11ed1ba Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 14 Oct 2026 19:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(read):=20T4=20=E5=90=8E=E7=AB=AF=C2=B7?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E7=BB=9F=E8=AE=A1+=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=8C=83=E5=9B=B4+=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四条读出路径(列表/详情/统计/导出)与管理动作行级把关共用 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)。 --- docs/T3-report.md | 2 +- docs/T4-report.md | 160 ++++++ sql/t3_answer_domain.sql | 8 +- .../common/FormFilterOperators.java | 234 ++++++++ .../formcenter/common/FormModuleKeys.java | 55 ++ .../common/FormSchemaValidator.java | 24 + .../common/FormValueSchemaBuilder.java | 152 +++++ .../formcenter/common/FormValueValidator.java | 20 +- .../controller/api/FormAdminController.java | 43 +- .../api/FormAnswerAdminController.java | 35 +- .../api/FormAnswerReadController.java | 134 +++++ .../controller/oapi/OapiFormController.java | 48 ++ .../com/yunhai/formcenter/entity/Answer.java | 26 +- .../yunhai/formcenter/enums/FormEnums.java | 46 ++ .../yunhai/formcenter/model/OapiModels.java | 72 +++ .../yunhai/formcenter/model/ReadModels.java | 194 +++++++ .../repository/AnswerIndexRepository.java | 4 + .../repository/AnswerRepository.java | 30 +- .../formcenter/repository/FormRepository.java | 18 +- .../service/AboutMeScopeImplementor.java | 35 ++ .../service/AllScopeImplementor.java | 29 + .../service/AnswerAdminService.java | 142 +++-- .../service/AnswerExportService.java | 267 +++++++++ .../service/AnswerIndexService.java | 122 +++- .../formcenter/service/AnswerReadService.java | 258 +++++++++ .../service/AnswerScopeImplementor.java | 38 ++ .../service/AnswerScopeQueryBuilder.java | 294 ++++++++++ .../formcenter/service/AnswerService.java | 42 +- .../service/AnswerStatsService.java | 242 ++++++++ .../formcenter/service/DataScopeService.java | 166 ++++++ .../formcenter/service/FormAdminGuard.java | 112 +++- .../formcenter/service/FormDefService.java | 74 ++- .../formcenter/service/ModuleTreeSource.java | 29 + .../formcenter/service/OapiFormService.java | 117 ++++ .../service/PlatformModuleTreeSource.java | 40 ++ .../service/PlatformSubjectDirectory.java | 74 +++ .../SelfSubmittedScopeImplementor.java | 35 ++ .../formcenter/service/SubjectDirectory.java | 29 + .../TeachingClassScopeImplementor.java | 42 ++ .../common/FormFilterOperatorsTest.java | 157 ++++++ .../common/FormValueSchemaBuilderTest.java | 121 ++++ .../common/FormValueValidatorTest.java | 13 +- .../formcenter/service/AnswerAdminIT.java | 34 +- .../service/AnswerConcurrencyIT.java | 2 + .../formcenter/service/AnswerExportIT.java | 189 +++++++ .../formcenter/service/AnswerReadIT.java | 520 ++++++++++++++++++ .../formcenter/service/AnswerSubmitIT.java | 44 +- .../formcenter/service/AnswerTestSupport.java | 71 ++- .../service/DataScopeServiceTest.java | 232 ++++++++ .../service/FormAdminGuardTest.java | 144 +++-- .../formcenter/service/FormFileServiceIT.java | 14 + .../service/InMemoryModuleTreeSource.java | 95 ++++ .../service/InMemorySubjectDirectory.java | 47 ++ .../formcenter/service/OapiFormServiceIT.java | 183 ++++++ .../service/PlatformSubjectDirectoryTest.java | 73 +++ .../formcenter/service/ReadFixture.java | 160 ++++++ .../yunhai/formcenter/service/XlsxReader.java | 60 ++ 57 files changed, 5402 insertions(+), 249 deletions(-) create mode 100644 docs/T4-report.md create mode 100644 src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java create mode 100644 src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java create mode 100644 src/main/java/com/yunhai/formcenter/common/FormValueSchemaBuilder.java create mode 100644 src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java create mode 100644 src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java create mode 100644 src/main/java/com/yunhai/formcenter/model/OapiModels.java create mode 100644 src/main/java/com/yunhai/formcenter/model/ReadModels.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AnswerExportService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AnswerReadService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java create mode 100644 src/main/java/com/yunhai/formcenter/service/AnswerStatsService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/DataScopeService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java create mode 100644 src/main/java/com/yunhai/formcenter/service/OapiFormService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java create mode 100644 src/main/java/com/yunhai/formcenter/service/PlatformSubjectDirectory.java create mode 100644 src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java create mode 100644 src/main/java/com/yunhai/formcenter/service/SubjectDirectory.java create mode 100644 src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java create mode 100644 src/test/java/com/yunhai/formcenter/common/FormFilterOperatorsTest.java create mode 100644 src/test/java/com/yunhai/formcenter/common/FormValueSchemaBuilderTest.java create mode 100644 src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java create mode 100644 src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java create mode 100644 src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java create mode 100644 src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java create mode 100644 src/test/java/com/yunhai/formcenter/service/InMemorySubjectDirectory.java create mode 100644 src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java create mode 100644 src/test/java/com/yunhai/formcenter/service/PlatformSubjectDirectoryTest.java create mode 100644 src/test/java/com/yunhai/formcenter/service/ReadFixture.java create mode 100644 src/test/java/com/yunhai/formcenter/service/XlsxReader.java diff --git a/docs/T3-report.md b/docs/T3-report.md index 43ccc8b..41a52f0 100644 --- a/docs/T3-report.md +++ b/docs/T3-report.md @@ -13,7 +13,7 @@ | 包 | 新增 | 承担 | | --- | --- | --- | | `entity` | `Answer` / `AnswerIndex` / `StatusLog` / `AnswerOpLog` / `FillQuota` / `FormFile` | 契约 §10 答卷域五表(`MEDIUMTEXT`、`v_str(191)`、`DECIMAL(20,6)`、`uk_answer_uuid`/`uk_answer_idem`/`uk_quota` 逐项对齐)+ 附件落档行(P24) | -| `repository` | 六个同名 `*Repository` | 全部 JPQL/派生查询,**零 native SQL**(P10);`AnswerRepository`/`FillQuotaRepository` **不提供任何物理删除方法**(P22/V30) | +| `repository` | 六个同名 `*Repository` | 全部 JPQL/派生查询,**零 native SQL**(P10);**answer 数据无物理删除通道**(P22/V30)——`AnswerRepository` 不提供删行方法;`FillQuotaRepository.releaseQuota` 的删除=**释放名额**(方案 §5.4 点名行为,V32 后半),不是答卷数据删除〔措辞按 T3 验收报告 §4.1 于 T4 更正〕 | | `common` | `FormValueValidator` | 契约 §3 值形状 ×11 放行类的提交期校验;附件值只认 `{id,fileName,key}` 且拒绝对 URL;字典候选由服务层注入(保持可脱库单测) | | `service` | `AnswerService`(提交编排)/ `AnswerIndexService`(索引写/重建/清理)/ `AnswerAdminService`(编辑/软删/改状态/批量)/ `FormFileService`(上传) | 见 §1.2–§1.4 | | `model` | `AnswerModels` | 提交/编辑/批量/上传出入参;请求体**没有** `schId/submitterId` 字段,响应只出现 `answerId`(uuid) | diff --git a/docs/T4-report.md b/docs/T4-report.md new file mode 100644 index 0000000..39a850b --- /dev/null +++ b/docs/T4-report.md @@ -0,0 +1,160 @@ +# T4 执行说明 · 后端·检索统计 + 数据范围 + 导出 + +> 任务书:《表单中心任务书-Qoder-20261014.md》v1.1 §T4|执行日期:2026-10-14|执行方:Qoder +> 输入权威:方案 v1.4(§5.2/§5.5/§6.1/§6.4/§9)+ 契约 v1.0(§2/§4/§8/§9/§10,含 10-14 T3 验收后回写) +> + 执行手册 §5 **P23~P34 裁决**(P25/P33/P34 前置三项、P21 守卫到期替换、P28/P30 归本轮的收紧点) +> + 《表单中心T3验收报告-20261014.md》§5 开工清单与 §4 三处勘误 +> 前置:T1/T2/T3 已验收。本轮对已验收文件的改动**全部是裁决驱动**(§1.6 逐条列明),未重构已验收行为; +> T2 的 67 条 + T3 的 54 条测试原样全绿(`AnswerAdminIT`/`AnswerSubmitIT` 因签名切换做了等义改写,见 §1.6)。 + +--- + +## 1. 做了什么 + +### 1.1 开工前置三项(T3 验收裁决落地,先做) + +| # | 落地点 | 实现 | +| --- | --- | --- | +| **P25** | `Answer.formUuid`(`varchar(32) NOT NULL updatable=false`)+ `sql/t3_answer_domain.sql` 同步加列;`AnswerService.doSubmit` 写入 `resolvePublished` 命中的表单 uuid;**`FormDefService.pinnedForData(formUuid, version)`** 改按 `form_uuid → form(uk_form_uuid) → 版本行` 精确取数 | **删除了 T3 的"本校缺版本行退平台级"回退启发式**(连带删除已无调用方的 `FormRepository.findDataDomainCandidates`)。理由与裁决一致:那组 `(校, app_code, code, version)` 坐标不含"表单归属校",遮蔽歧义时同版本号会**静默按错 schema** 校验/重建索引,比查不到更糟。开发库答卷零行 → 无回填(`ddl-auto=update` 直接加列,实测见 §3) | +| **P33** | 新接口 `SubjectDirectory.existsInSchool(subjectId, schId)` + 生产实现 `PlatformSubjectDirectory`(SDK `OpenApiUserClient.getUserById` → UserBase `GET /oapi/user/byId?id=`,签名由 starter 出站链负责) | 提交(`AnswerService.requireSubject`)与编辑(`AnswerAdminService.assertSubjectStillValid`)两处,**凡代填**(`subject_id` 非空且 ≠ 提交人)判存在性 + 同校归属,不过则 `PARAM_ERROR`。本人自填不查(会话里的人必然存在)。归属判定取 `schools`(全部可选身份学校,本期 UserBase 起填)为主、`school`/`currentRole.schId` 为兜底(`PlatformSubjectDirectoryTest` 逐层断言) | +| **P34** | 两处 `assertAttachmentsOwned`(提交 / 编辑) | 查到 `form_file` 行后比对值里 `key == store_key`、`fileName == file_name`,不一致 `PARAM_ERROR`。配套把 `FormValueValidator.attachmentFileIds` 改为 `attachments()`(返回完整三件套,否则要比 key 就得把值文档再走一遍) | + +外部调用一律收在策略接口后面:业务服务不直连 UserBase 库、不私写签名;本地 IT 不启动 UserBase/CAS,用注入桩取证,真实链路留 T9 联调(§2 环境事实)。 + +### 1.2 P21 守卫到期替换:`FormAdminGuard` 从角色闸门切"授权模块集" + +- 新接口 `ModuleTreeSource`(+ 生产实现 `PlatformModuleTreeSource` 走 SDK `YschBaseOpenApi.getUserModule`,即 UserBase `/oapi/user/modules`),`roleId∈{2,7}` 硬编码**整体替换、不保留回落**; +- 管理权 = 被授权对应模块行,四个 path 常量逐字对齐手册 §2.2 表:`/admin/forms` 定义管理、`/admin/data` 数据管理、`/admin/stats` 统计、`/admin/dicts` 字典管理(`requireAdmin(user, targetSchId, modulePath)` 25 处调用点全部按面归类); +- **超管例外保留**:`type==1` 可跨校且唯一可管 `sch_id=0` —— 那是 UserBase 用户类型不是 roleId,不属于 P21 要移除的硬编码(方案 §6.5"超管不受单校开通约束"); +- `canManage` 同步切口径 = 超管 或 被授权 `/admin/data` 或 `/admin/forms`(代填分支用); +- `requireAdminAny`:统计按手册"可并入数据管理,二选一"放行 `/admin/stats` 或 `/admin/data`; +- **与数据范围正交**:管理权看 `path`、范围看 `customId`,`FormModuleKeys` 把两者分开取(`scopeAnchorIsNotAModuleRow` 用例钉住"path 空锚点不给管理权")。 + +### 1.3 数据范围(契约 §8 / 方案 §6.4) + +``` +FormEnums.DataScope{ ALL, TEACHING_CLASS, SELF_SUBMITTED, ABOUT_ME } // 闭集四枚举 +AnswerScopeImplementor(策略接口,一值一实现器:×3 一期可用 + 任教班级 1.5 占位) +DataScopeService.resolve(会话用户, 表单) → 范围集合 +AnswerScopeQueryBuilder ← 四条读出路径 + 管理动作行级把关的唯一谓词构造器 +``` + +- **矩阵 × audience**:管理员(7)/校领导(2)=全部;教师(1)×面向教师=仅本人提交,×面向学生=任教班级**一期实现器缺席 → 生效仅本人提交**(方案表格明示要防"教师怎么只看到自己"被当 bug 报);学生(3)×面向学生=关于我;家长(4)/工作号(6)/局领导(5) 与未知角色=回落仅本人提交;**全不命中=仅本人提交**。 +- **customId 扫描**:授权模块树里 path 空、`customId=fc:scope:all` 的行 → 升级全部(递归扫描,锚点在子层也算命中)。 +- **多来源取最宽 = 并集**:一个人有多个身份时逐格取默认再 OR(学生"关于我"与教师"仅本人提交"不是包含关系,取单值会丢一格);命中"全部"即收敛。 +- **任教班级**:枚举里有、`selectable()=false`、解析永不产出、被误用时明确抛"一期未开放"(V28 的三半各有断言)。 + +### 1.4 四条铁律的落点(方案 §5.5) + +| 铁律 | 实现 | 取证 | +| --- | --- | --- | +| ① 两段式列表禁 SELECT values | `AnswerScopeQueryBuilder.pageIds` 只 `select a.id`(Criteria 投影,类型上就取不到大列);当页文档走 `AnswerRepository.findLiveByIds` | `AnswerReadIT.listIsTwoPhaseAndLocatorNeverSelectsValuesColumn`——把 Hibernate SQL 接到内存 appender,断言第一段 SQL 不含 `values_json`、第二段才含 | +| ② keyset 仅提交时间序 | 游标恒落 `id`(与 create_time 同序单调),`asc/desc` 只换方向;**没有排序列参数、没有 offset** | `keysetPagesWithoutOverlapAndCursorIsScopeBound`:size=2 翻 3 页不重不漏;游标 uuid 不在可见集内 → `游标不可用` 拒 | +| ③ 查询覆盖索引化 | 列表/计数/导出落 `idx_answer_form_time(sch_id,app_code,code,is_deleted,id)`;筛选落 `answer_index` 三复合索引的子查询;按状态分组落 `idx_answer_form` | DDL 与实体索引逐条对齐(§1.7 记 P35);筛选在 SQL 层完成,多条件 AND 叠加 | +| ④ 统计缓存仅升版失效 + 短 TTL | `AnswerStatsService`:键=`域 + form_uuid + 当前发布 version + 范围签名 + 用户 + 分组字段`,TTL 60s,只缓存无筛选条件的全量统计;**不按数据变更失效** | `statsCacheIsVersionScopedAndNotInvalidatedByNewData`:新答卷落库后 TTL 内统计总数不变(预期行为)、`cached=true`,而列表实时可见新行;换身份不命中同一键 | + +### 1.5 检索 / 统计 / 导出 / oapi + +- **列表**(`AnswerReadService.list`):两段式 + keyset,返回行含 `answerId(uuid)/version/status(+label)/submitterId/subjectId/answerNo/submitTime/values`;`scopes` 一并回传便于前端与验收对照。值文档原样返回,三态空值与已删字段降级由前端做。 +- **详情**(`detail`):uuid 寻址,返回**钉住版本**(`form_uuid+version`)内联展开后的 schema + 值文档;可见性用同一构造器判(不是 Java 里比字段)→ 越权 `FORBIDDEN`。 +- **筛选候选**(`filterFields` + `FormSchemaValidator.filterableFields`):`searchable` 且**有算子**的字段;`upload/image` 排除(**P28**:不发明算子、不写索引行、也不进候选),按它筛选被点名拒绝。既有定义面 `/api/form/admin/filterFields` 同批改为排除附件类。 +- **算子收敛**(`common/FormFilterOperators`):等值(选项类)、前缀模糊(文本类,LIKE 转义 `\ % _`)、闭区间(date→`v_date`、number→`v_num`);类型由 schema 定,形态不对一律 `PARAM_ERROR`(日期严格 `yyyy-MM-dd`,V4 不走字典序)。 +- **统计**(`AnswerStatsService`):总数 / 按状态 / 按 searchable 字段分组(走 `answer_index` + `count(distinct answer_id)`,多值字段一答卷不因多值重复计数);选项桶 label 回查**展开后**的 options。 +- **导出**(`AnswerExportService`):easyexcel 3.3.2 动态表头 + `ExcelWriter` 分批 `write`,批大小 500,游标逐批前移;同步响应写 `HttpServletResponse` 输出流(不套 JSON 信封,`void` 返回);范围=单表单+可选筛选,无行数上限、无异步;列=六列身份/时间 + 当前发布版本字段序,**选项列显 label、多选按 options 定义序、附件列显 fileName**。 +- **软删排除**:`is_deleted=0` 写在构造器里,四条路径与管理动作一律带上(V30 读侧半边)。 +- **oapi**(`OapiFormController` + `OapiFormService`):`GET /oapi/form/schema?schId=&code=[&appCode=]`(内联 options + 每字段 `valueSchema`,`audience` 下发枚举名)与 `GET /oapi/form/answer?schId=&answerId=`(钉住版本 schema + 值文档);沿用 `/oapi/*` 验签墙与 `code=0` 信封;`resolvePublished` 与填报入口同一条遮蔽实现。 + +### 1.6 对本轮之前已验收文件的改动(全部裁决驱动,非重构) + +| 文件 | 改了什么 | 依据 | +| --- | --- | --- | +| `entity/Answer` | + `form_uuid` 列 + `idx_answer_form_time` 索引 | P25;方案 §5.5 铁律③(索引记 P35) | +| `repository/FormRepository` | + `findByUuid`;**删除 `findDataDomainCandidates`**(改按 form_uuid 取数后无调用方) | P25 | +| `repository/AnswerRepository` | + `findLiveByIds`(两段式第二段)、`findLiveByFormUuidAfterId`(升版补建分批扫描);类注释补"四条读路径在构造器不在仓库" | 铁律①/§6.4 红线;V9(记 P36) | +| `repository/AnswerIndexRepository` | + `findFieldKeysByAnswerId`(补建去重) | V9 | +| `service/FormDefService` | `pinnedForData` 换成 `(formUuid, version)` 形态并删平台级回退;新增 `readDomainForm`;`publish` 末尾调用索引补建;`MAX_CODE_LEN_FOR_ANSWER_NO` **行内注释按 49 口径更正**(T3 验收 §4.2 勘误) | P25、P32、V9、勘误② | +| `service/AnswerService` | 提交写 `form_uuid`;`requireSubject` 增存在性+同校;`assertAttachmentsOwned` 增 key/fileName 比对 | P25/P33/P34 | +| `service/AnswerAdminService` | 方法签名 `(..., Integer operatorId, ...)` → **`(..., BoaUser operator, ...)`**;每条动作前过行级范围(**P30 收紧点**);`pinnedForData` 新坐标;P33/P34 两处校验;批量逐条判范围 | P25/P30/P33/P34、V39 范围半边 | +| `service/AnswerIndexService` | `write` 抽出 `rowsFor`;新增 `backfillAfterPublish`(只增不删、幂等、按 id 游标分批) | V9(记 P36) | +| `service/FormAdminGuard` | 角色闸门 → 授权模块集;+`requireAdminAny`/`authorizedPaths` | P21 到期替换 | +| `common/FormValueValidator` | `attachmentFileIds` → `attachments`(返回三件套节点) | P34 | +| `common/FormSchemaValidator` | + `filterableFields`(searchable 且非附件类) | P28 | +| `enums/FormEnums` | + `DataScope` 四值;`Audience.nameOf`(oapi 下发枚举名) | 契约 §8/§4 | +| `controller/api/FormAdminController` | 20 个端点按 `/admin/forms`(定义 14)与 `/admin/dicts`(字典 6)归类;`filterFields` 排除附件类 | P21、P28 | +| `controller/api/FormAnswerAdminController` | 5 个端点改 `/admin/data` 口径并把 `user` 传进服务 | P21、P30 | +| `src/test/.../AnswerTestSupport` | + 两个网关桩(`InMemoryModuleTreeSource`/`InMemorySubjectDirectory`,`@TestConfiguration`+`@Primary`)、`manager()` 助手、roleId=7 默认授权中心模块行 | P21/P33 落地后的测试形态 | +| `src/test/.../{AnswerSubmitIT,AnswerAdminIT,AnswerConcurrencyIT,FormFileServiceIT}` | 调用点随签名改写(**断言语义未变**);`FormAdminGuardTest` 按新口径重写(5→9 条) | 同上 | +| `docs/T3-report.md` | §1.1 "无物理删除"措辞更正 + §2 V32 "两个不同教师"注明实为两个 roleId=7 操作者 | T3 验收 §4 勘误①③ | +| `sql/t3_answer_domain.sql` | answer 加 `form_uuid` 列与 `idx_answer_form_time`,脚本头记 T4 变更说明 | P25/铁律③ | +| `application.yml` | **一字未改**(P8:`/oapi` 真实消费方出现再补 trusted-apps;教训注释原样保留) | P8 | + +### 1.7 环境动作(本机副作用) + +- 复用本地 MySQL 8.0.45(3306)+ 库 `bsch_formcenter`:`ddl-auto=update` 给 `answer` 追加 `form_uuid varchar(32) NOT NULL` 与 `idx_answer_form_time`,`SHOW COLUMNS`/`SHOW INDEX` 实测已生效(§3);当时表零行 → 无回填、无锁表风险。 +- **零残留**:全量 177 条测试跑完后十张表实测 `count(*)=0`(IT 一律 `@Transactional`,并发 IT 靠 `@AfterEach` 定点清理)。 +- 未启动 CAS/UserBase/Redis;未触碰 160 共享环境;`local-debug/t4-read-smoke.sh` 启动冒烟用 `target/form-center.jar` 直跑(无 env 注入,走 yml 本地兜底键)。 + +--- + +## 2. 自验证据(逐 V) + +**门禁**:`mvn -B -o clean package` → **BUILD SUCCESS,Tests run: 177, Failures: 0, Errors: 0, Skipped: 0**,产物 `target/form-center.jar`。 +本轮新增 **56 条**(`DataScopeServiceTest` 15 + `AnswerReadIT` 14 + `FormFilterOperatorsTest` 7 + `FormValueSchemaBuilderTest` 5 + `AnswerExportIT` 4 + `OapiFormServiceIT` 4 + `PlatformSubjectDirectoryTest` 2 + `AnswerSubmitIT` +1 + `FormAdminGuardTest` 5→9),其中 **23 条打真实 MySQL**;T2 的 67 条与 T3 的 54 条一条未改语义仍全绿。 +测试形态按任务书:纯逻辑走 `*Test`(不启 Spring),列表/统计/导出走 `*IT`(真库 + 注入桩顶掉 UserBase 两件事源)。 + +| V | 断言(方案 §9 原文要点) | 证据 | +| --- | --- | --- | +| **V9** | 新增 searchable 字段全程无 DDL,旧数据按新字段筛选正确 | `AnswerReadIT.searchableFieldAddedInV2FiltersOldAnswers`:v1 里 `advisor` 存在但不 searchable → 不在候选;发布 v2 声明 searchable(**零 DDL**)→ 候选出现该字段,且**三条 v1 期写的答卷**按 `advisor` 前缀筛出 `{张三, 代填张三}`、按"张老师"筛出两条教师行。机制=发布时 `backfillAfterPublish` 补索引行(只增不删,与 V8 同向) | +| **V11** | 统计返回总数/按状态/按字段分组,前端不做聚合 | `AnswerReadIT.statsReturnsTotalStatusAndFieldGroups`:`total=5`、`byStatus=[pending/待处理:5]`、`byField(grade)=[g2/高二:3, g3/高三:2]`(label 由 options 回查);带状态过滤时总数与桶一起窄化 | +| **V23** | 关闭 open-in-view 后详情/列表/导出无 `LazyInitializationException` | 三条路径全在 `@SpringBootTest`(`open-in-view=false` 生效的那份配置,`detailReturnsPinnedSchemaAndValues` 显式断言该属性为 false)下跑通;实体无关联映射,谓词与取数均为显式批量 | +| **V27** | 范围贯穿:任一范围身份调统计/导出,结果集 ⊆ 其列表可见集(四路径同一构造器) | `AnswerReadIT.statsAndExportAreSubsetsOfListForEveryIdentity`:对 **6 个身份**(管理员/教师1/教师2/学生1/学生2/局领导)逐个把列表可见集当基准,断言 `stats.total == |列表|`、`按状态求和 == 总数`、`导出行数 == |列表|`、**且导出的 uuid 集合 ⊆ 列表集合**(不只比数值)。`AnswerExportIT.exportIsScopeBoundPerIdentity` 再验教师 3 行=其列表 3 行、外来身份 0 行且仍产出带表头的合法文件 | +| **V28** | 任教班级出现在契约枚举中但一期不可选 | `DataScopeServiceTest.teachingClassIsInEnumButNotSelectable`(枚举四值齐全、`selectableScopes()` 只三个)、`noCombinationResolvesToTeachingClass`(7 角色 × 2 人群全遍历解析不出)、`TeachingClassScopeImplementor` 被误用时抛"一期未开放";`FormAdminGuardTest` 另钉住"锚点不给管理权" | +| **V37** | 导出范围=单表单+筛选、行数=范围总数(非当页)、选项列显 label、附件列显 fileName、受限身份 ⊆ 可见集 | `AnswerExportIT` 四条:25 行数据(页大小 20)导出 **25 行**且表头逐列断言;`select` 出"高二年级"而非 `g2`、多选 `["phy","math"]` 出**定义序**"数学、物理"、`image` 列出"奖状.jpg"(值里无 URL)、第一列是 32 位 uuid;筛选 `grade=g3` 只出高三 3 行、非法筛选 key 被拒不静默导全量;受限身份逐身份子集断言 | +| **V42** | 角色矩阵验默认、customId 授权升级、全不命中回落、任一范围身份统计/导出 ⊆ 列表 | 单元半边 `DataScopeServiceTest`(矩阵逐格 + 并集 + 回落 + 超管 + 锚点递归);接口半边 `AnswerReadIT.matrixResolvesPerContractSectionEight`、`scopeAllAnchorUpgradesTeacher`(教师授权锚点后 `scopes=[ALL]`、列表 5 行)、`statsAndExportAreSubsetsOfListForEveryIdentity` | +| **V30 读侧半边**(编排补充③) | 软删答卷在列表/详情/统计/导出均不可见 | `AnswerReadIT.softDeletedAnswerIsInvisibleOnAllFourPaths`:列表不含、详情 `答卷不存在或已删除`、统计 `total=5`(未删数)、导出 5 行且 uuid 集合不含(T3 已验索引删行与管理侧,本轮补四条读路径) | +| **V39 范围半边**(编排补充①) | 批量软删/改状态同受数据范围约束(与 V27 同构造器) | `AnswerReadIT.detailIsScopeBound`:教师1(**已授权 `/admin/data` 模块行**,能进接口)批量改状态 `[自己的, 别人的]` → 第一条成功、第二条逐条报"不在你的数据范围内";留痕形态与单条一致由 T3 的 `AnswerAdminIT.batchIsPerItemValidationAndLogging` 承担 | +| **V3 筛选候选 / V4 日期 / P28** | 未声明 searchable 不进候选;日期按日期比;附件无算子 | `FormFilterOperatorsTest`(算子逐类、`2023-1-9` 被拒、`upload` 明确"无检索算子"、候选集排除 cert 而索引口径不变)、`AnswerReadIT.operatorsAreTypeBound`(等值/前缀/区间/日期区间在真库上命中正确集合;LIKE 转义后 `%` 作为输入不命中全部)、`attachmentFieldsAreNotFilterable` | +| **V1/V4 契约形状 / §4 下发** | schema 端点下发内联 options + valueSchema | `FormValueSchemaBuilderTest` 5 条(11 类逐类形状、不发明 `multipleOf/uniqueItems`、附件 `additionalProperties:false`、入参不被改写);`OapiFormServiceIT.schemaIsSelfContainedWithInlineOptions`(字典已内联、`dict:grades` 不出现在出参、`audience="STUDENT"`) | +| **V5/V8 相邻面 + P25** | uuid 寻址、id 不外露、历史答卷按钉住版本读改 | `AnswerReadIT.detailReturnsPinnedSchemaAndValues`(升版后详情仍给 v1 形状)、`platformFormShadowedBySchoolFormStillValidatesItsOwnAnswers`(**P25 用例**:平台表单被本校同 code 遮蔽后,历史答卷编辑按原 schema 放行、夹带本校新字段被拒"未知字段")、`AnswerSubmitIT.adminCanFillOnBehalf` 断言 `form_uuid=resolvePublished 命中行` | +| **V17/V18 前置项加固** | 代填 subject 真实且同校 | `AnswerSubmitIT.proxySubjectMustExistInSessionSchool`(查无此人 / 跨校 → `PARAM_ERROR`;同校放行;本人自填不触发查询)、`PlatformSubjectDirectoryTest`(三层归属字段 + fail-closed) | +| **V26 相邻面** | 无授权模块行者后端拒 | `FormAdminGuardTest`(未授权拒、四模块行互不通用、`/admin/stats` 或 `/admin/data` 二选一、跨校与平台级拒绝、无会话=UNAUTHORIZED) | +| **P34** | 附件 key 与 store_key 一致 | `FormFileServiceIT.submittedAttachmentIdsMustBeOwned` 增两例:id 真、key 指向别的相对路径 → "附件 key 与上传记录不一致";fileName 改名 → "附件文件名与上传记录不一致";正向原样转存不受影响 | + +**其它取证** + +| 项 | 结果 | +| --- | --- | +| 启动冒烟 | `bash local-debug/t4-read-smoke.sh` → **9/9 PASS**:health=200、`Started FormCenterApplication`;`/api/form/admin/data/{list,detail,filterFields,stats,export}` 无票均 302→CAS(POST 型用 `-X POST -d '{}'` 打,证明映射存在而非 404);`/oapi/form/{schema,answer}` 401 验签墙 | +| 端点清点 | **37**(按方法级 `@GetMapping/@PostMapping` 逐条计):`FormAdminController` 20 + `FormBaseController` 2 + `FormResolveController` 1 + `FormAnswerController` 2 + `FormAnswerAdminController` 5 = 30(T1~T3 既有)+ `FormAnswerReadController` 5 + `OapiFormController` 2 = **T4 新增 7**。同口径复核 T3 提交(`git grep -c` on `bff0fb1`)是 30 个,而 T3 报告写"28=21+7"——那是按 `Mapping(` 行数计的另一种口径(含类级 `@RequestMapping`、且 21+7≠28),**本轮起以 30/37 这组数为准**(验收侧若沿用了 28 需同步)。全仓 **零 `nativeQuery`**(MySQL 5.7 兼容口径不变,P10) | +| 红线自检 | 四条读出路径 + 管理动作的谓词全部出自 `AnswerScopeQueryBuilder`(`AnswerReadService`/`AnswerStatsService`/`AnswerExportService`/`AnswerAdminService` 四处调用点,均先经 `scopeQuery` 取规格);构造器不接受"跳过范围"开关,空范围直接 `FORBIDDEN` | +| 错误码(P14 定稿四段) | 游标不可见/筛选形态错/分组字段不可用/附件 key 不符/被代填人无效/表单·答卷未找到=`PARAM_ERROR(2)`;未登录=`UNAUTHORIZED(4)`;无模块权·越范围=`FORBIDDEN(3)`;任教班级未开放·范围签名缺失·未发布=`CUSTOM(5)`。无新增枚举 | +| 容量口径 | 列表页上限 100(超出截断);导出批 500;索引补建批 500;统计缓存条目上限 256(超出整体清空)——都是安全阈值,不是契约条款 | + +--- + +## 3. 问题单(本轮登记,供执行手册 §5 裁决;编号接续 P34) + +| # | 问题 | 本轮取值(最保守解读) | 影响面 | +| --- | --- | --- | --- | +| P35 | 契约 §10 的 `idx_answer_form(sch_id,code,status,id)` 对"单表单 + 未删 + 按 id keyset"的列表/导出不是覆盖索引(不含 `app_code`,且 `status` 夹在中间无法跨状态给 id 序) | **新增 `idx_answer_form_time(sch_id,app_code,code,is_deleted,id)`**(纯加项,不改任何既有列/uk/索引语义)。要否并入契约 §10 请裁决;V10 压测时 `EXPLAIN` 复验选择性 | 铁律③;生产 `ddl-auto=none` 需按脚本追加 CREATE INDEX | +| P36 | V9"旧数据按新 searchable 字段筛选"要求历史答卷有索引行,而提交时只按当时钉住版本写行——方案 §5.2/§5.5 未指定补建机制 | **发布时同步补建**(`AnswerIndexService.backfillAfterPublish`:按 `form_uuid` 未删答卷 id 游标分批、只插缺失 field_key、只增不删、幂等)。代价记账=大表单升版发布多一次全量值文档扫描(2 万级秒级);若裁决改异步任务,改点在 `FormDefService.publish` 一处。不做半成品双路径 | V9;升版耗时;R12 引用影响面 | +| P37 | 方案 §5.5 点名"大表单全量分组统计加 **Redis** 缓存",本轮实现为进程内 `ConcurrentHashMap` + TTL | 进程内缓存:键含 `form_uuid+version+范围签名`、仅升版换键失效、TTL 60s、条目上限 256、只缓存无筛选的全量统计。**不改失效语义**(铁律④完全一致),改的是载体:本地无 Redis 即可跑通门禁,且少一个跨请求共享状态;多实例各自缓存对"容忍 N 分钟延迟"无实质影响 | 若要求一期就上 Redis,替换 `AnswerStatsService` 的 `cache` 字段即可(键与失效策略照用) | +| P38 | 契约 §8"多来源取最宽"在 `仅本人提交` 与 `关于我` 两个不可比较值之间取哪个,文档未写 | **取并集(谓词 OR)**,多身份用户两格都可见;命中 `全部` 即收敛。理由:单值取舍会丢掉一整格(学生+教师双身份者看不到"关于我"),而并集是"最宽"的字面实现且不引入包含关系误判 | 双身份用户的可见集;V42 断言形态 | +| P39 | 铁律②游标是库内 `answer_id`,但 V5 要求 `id` 不出现在任何响应外层 | 对外游标=**当页末行 uuid**,服务端按 `uk_answer_uuid` 点查换成 id,且游标本身也过范围(不可见即拒)。每页多一次索引点查 | URL 形态(T8 翻页参数用 `cursor`=uuid) | +| P40 | 契约 §9 的 schema 端点只写 `?schId=&code=`,快应用表单(`app_code≠form-center`)无法按该形状取 | 加**可选 `appCode`**(不传=form-center,缺省行为与契约文本一致)。是否回写契约 §9 请裁决 | L2 消费方接快应用表单时 | +| P41 | 契约 §9 说 answer 返回"钉住版本的 schema 引用",但 §9 的 schema 端点只下发**当前发布版**——只给引用则消费方拿不到当时那张表 | data 里同时带 `schemaRef{formUuid,appCode,code,version}` 与**钉住版本内联展开后的 `groups`**(自包含,同 §4 原则)。要"只给引用"就删 `groups` 字段,但消费方需另开一个按版本取 schema 的端点 | 对外载荷体积;快照语义 | +| P42 | P33 校验依赖 UserBase,**不可达时**按"查不到"还是"跳过校验"未写 | **fail-closed**:不可达与查不到同样 `PARAM_ERROR`(日志分档便于排查,同 flow-center `requireUserName` 口径)。代填是低频动作,宁可让管理员重试 | UserBase 故障时管理员代填不可用;真实链路 T9 验 | +| P43 | 导出的列集合与"提交人/被代填人"显示形态,方案 §5.5/§6.4 未写 | 列=当前发布版本 schema 字段序(历史版本独有字段不出列,但该行仍在、身份列完整);两个身份列导出 **id 而非姓名**(姓名需按行批量查 UserBase,一期不引 N+1 远程调用)。要否"最新 schema ∪ 历史 searchable"请裁决 | V37 列形态;T9 压测的导出耗时 | +| P44 | 管理权与数据范围都来自 UserBase 模块树,一个读请求最多两次 `byId/modules` 调用 | 一期不做缓存(每请求 1~2 次签名调用),与 `FormBaseController` 现行形态一致。压测暴露再谈短 TTL 缓存或控制器内一次取树两处复用 | 列表/统计 p95(V10 阈值 1.5s/3s)——**T9 关注项** | +| P45 | 手册 §2.2 说统计模块行"可并入数据管理,二选一",接口按哪个判 | 新增 `requireAdminAny`,`/admin/stats` 或 `/admin/data` **任一命中**即放行统计接口(其余接口仍单行精确判) | 学校侧模块行配法自由度 | +| P46 | `canManage`(代填权)在"授权模块集"口径下该覆盖哪几行,P21 只说"整体替换" | 超管 或 `/admin/data` 或 `/admin/forms`(与 P21 期 roleId∈{2,7} 等宽,避免替换口径顺手收窄已验收行为)。只授权 `/admin/dicts` 者**不能**代填(已测) | V17/V32 的代填分支 | + +`trusted-apps` 本轮**未动**(P8:真实消费方出现再补)。yml 与 T3 验收时逐字一致。 + +--- + +## 4. 推送与 CI + +(待回填:提交号、推送区间、Gitea Actions run 结论。) + +—— 全文完 —— diff --git a/sql/t3_answer_domain.sql b/sql/t3_answer_domain.sql index 0e2f2be..2ef7435 100644 --- a/sql/t3_answer_domain.sql +++ b/sql/t3_answer_domain.sql @@ -12,11 +12,16 @@ -- P5/P15 裁决:实体继承 BaseEntity,实表=契约草案 + create_by/update_by 两列; -- 审计列在基类未标 nullable=false,故按建表实态写 DEFAULT NULL(Listener 恒写 now(),业务上不为空)。 -- 本机取证:MySQL 8.0.45 / bsch_formcenter 由 ddl-auto=update 建表后 SHOW CREATE TABLE 逐列比对一致。 +-- T4 变更(本轮):①answer 增列 `form_uuid VARCHAR(32) NOT NULL`(P25 裁决落地,钉版溯源精确坐标); +-- ②answer 增索引 `idx_answer_form_time`(方案 §5.5 铁律③,四读出路径 keyset 游标的覆盖索引)。 +-- 开发库 answer 当时零行 → NOT NULL 无需回填;已建库由 ddl-auto=update 追加列与索引, +-- 生产(ddl-auto=none)升级即照本脚本这两条 ALTER/CREATE INDEX 执行。 -- ============================================================ CREATE TABLE `answer` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `uuid` VARCHAR(32) NOT NULL COMMENT '数据面运行时身份(详情 URL/嵌入地址/L2 引用,契约 §1)', + `form_uuid` VARCHAR(32) NOT NULL COMMENT '提交时解析命中的表单 uuid——钉版溯源的精确坐标(P25 裁决:遮蔽歧义根治,读改路径一律按它取版本行)', `sch_id` BIGINT NOT NULL COMMENT '恒=会话校(校域不变式:跨校提交在解析层即不可能)', `app_code` VARCHAR(64) NOT NULL, `code` VARCHAR(64) NOT NULL, @@ -37,7 +42,8 @@ CREATE TABLE `answer` ( UNIQUE KEY `uk_answer_idem` (`idempotency_key`), KEY `idx_answer_form` (`sch_id`, `code`, `status`, `id`), KEY `idx_answer_submitter` (`sch_id`, `submitter_id`), - KEY `idx_answer_subject` (`subject_id`) + KEY `idx_answer_subject` (`subject_id`), + KEY `idx_answer_form_time` (`sch_id`, `app_code`, `code`, `is_deleted`, `id`) COMMENT 'T4 铁律③:单表单列表/导出/计数 keyset 游标的覆盖索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='答卷(方案 §5.1:定义与答卷物理分离)'; CREATE TABLE `answer_index` ( diff --git a/src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java b/src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java new file mode 100644 index 0000000..6437176 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormFilterOperators.java @@ -0,0 +1,234 @@ +package com.yunhai.formcenter.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.model.ReadModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 检索算子收敛(契约 §2 最后一句):算子按字段 type 定,不给自由表达式—— + * 等值(选项类)、区间(date/number)、前缀模糊(文本类)。 + * + *

调用方只能提交 {@code {key,value}} 或 {@code {key,from,to}};本类按钉住当前发布版本的 + * 字段类型决定它落到 {@code answer_index} 的哪一列(v_str / v_num / v_date)、用哪种算子, + * 形态不对就拒("给 text 传 from/to"、"给 date 传区间外的串"、"给 number 传非数字"都不是筛选语义, + * 是查询注入的入口)。类型正确性在这里保证,V4 的"日期按日期比、不走字典序"也在这里保证。

+ */ +public final class FormFilterOperators { + + /** 算子 */ + public enum Kind { + /** 等值(选项类,多值字段一值一行故命中任一项即算匹配) */ + EQ, + /** 前缀模糊(文本类;v_str 已截 191,超长部分不可筛,契约 §10 认可) */ + PREFIX, + /** 闭区间(date / number) */ + RANGE + } + + /** 落到的索引列 */ + public enum Column { + STR, NUM, DATE + } + + private static final Set OPTION_TYPES = new LinkedHashSet<>(Arrays.asList( + "select", "radio", "checkbox", "multiselect")); + private static final Set TEXT_TYPES = new LinkedHashSet<>(Arrays.asList( + "text", "textarea", "phone")); + + private FormFilterOperators() { + } + + /** + * 一次筛选条件的解析结果(读路径构造器只认这个,不再回看 schema)。 + * + *

数值/日期恒为闭区间:等值就是两端同值的区间,因此 {@code numFrom/numTo} + * (或 {@code dateFrom/dateTo})总是成对非空——构造器只用一个 {@code between} 分支, + * 不给"漏判一种算子"留余地。文本类才有 {@code strValue}(等值或前缀)。

+ */ + public static final class Spec { + private final String key; + private final Kind kind; + private final Column column; + private final String strValue; + private final BigDecimal numFrom; + private final BigDecimal numTo; + private final LocalDate dateFrom; + private final LocalDate dateTo; + + private Spec(String key, Kind kind, Column column, String strValue, + BigDecimal numFrom, BigDecimal numTo, LocalDate dateFrom, LocalDate dateTo) { + this.key = key; + this.kind = kind; + this.column = column; + this.strValue = strValue; + this.numFrom = numFrom; + this.numTo = numTo; + this.dateFrom = dateFrom; + this.dateTo = dateTo; + } + + public String getKey() { + return key; + } + + public Kind getKind() { + return kind; + } + + public Column getColumn() { + return column; + } + + public String getStrValue() { + return strValue; + } + + public BigDecimal getNumFrom() { + return numFrom; + } + + public BigDecimal getNumTo() { + return numTo; + } + + public LocalDate getDateFrom() { + return dateFrom; + } + + public LocalDate getDateTo() { + return dateTo; + } + } + + /** 该类型的算子名(筛选候选下发给前端,前端按它出控件) */ + public static String operatorOf(String type) { + if (OPTION_TYPES.contains(type)) { + return Kind.EQ.name(); + } + if (TEXT_TYPES.contains(type)) { + return Kind.PREFIX.name(); + } + if ("number".equals(type) || "date".equals(type)) { + return Kind.RANGE.name(); + } + return null; + } + + /** + * 该类型落地的索引列(分组统计按它取 {@code v_*} 列);无算子类型返回 null(不可分组)。 + * 与 {@link #resolve} 的列选择是同一条判断的两种问法,放在这里防两处口径分叉。 + */ + public static Column columnOf(String type) { + if (OPTION_TYPES.contains(type) || TEXT_TYPES.contains(type)) { + return Column.STR; + } + if ("number".equals(type)) { + return Column.NUM; + } + if ("date".equals(type)) { + return Column.DATE; + } + return null; + } + + /** + * 解析一个筛选条件。 + * + * @param field schema 里的字段节点(含 type) + * @param item 请求侧条件 + * @throws BizException PARAM_ERROR——类型无算子、形态不匹配、值解析不出正确类型 + */ + public static Spec resolve(JsonNode field, ReadModels.FilterItem item) { + String type = field.path("type").asText(); + String key = item == null ? "" : item.getKey(); + if (OPTION_TYPES.contains(type)) { + return new Spec(key, Kind.EQ, Column.STR, requireText(field, item), null, null, null, null); + } + if (TEXT_TYPES.contains(type)) { + return new Spec(key, Kind.PREFIX, Column.STR, requireText(field, item), null, null, null, null); + } + if ("number".equals(type)) { + BigDecimal from = parseNumber(field, item.getFrom()); + BigDecimal to = parseNumber(field, item.getTo()); + if (from == null && to == null) { + // 单值给成 value 也算等值("分数=95.5"是常见问法),落到同一列的数值区间 + BigDecimal eq = parseNumber(field, item.getValue()); + if (eq == null) { + throw fail(field, key, "number 筛选需要 value 或 from/to"); + } + from = eq; + to = eq; + } else if (from == null || to == null) { + // 只给一端:另一端补成同值——半开区间不是契约给的算子,不做"默认为无穷"的猜测 + BigDecimal bound = from == null ? to : from; + from = bound; + to = bound; + } + return new Spec(key, from.compareTo(to) == 0 ? Kind.EQ : Kind.RANGE, Column.NUM, + null, from, to, null, null); + } + if ("date".equals(type)) { + LocalDate from = parseDate(field, item.getFrom()); + LocalDate to = parseDate(field, item.getTo()); + if (from == null && to == null) { + LocalDate eq = parseDate(field, item.getValue()); + if (eq == null) { + throw fail(field, key, "date 筛选需要 value 或 from/to(yyyy-MM-dd)"); + } + from = eq; + to = eq; + } else if (from == null || to == null) { + LocalDate bound = from == null ? to : from; + from = bound; + to = bound; + } + return new Spec(key, from.equals(to) ? Kind.EQ : Kind.RANGE, Column.DATE, + null, null, null, from, to); + } + // upload/image/user/users/address:契约 §2 没给算子(附件那条另见 P28)——不发明算子 + throw fail(field, key, "该类型一期无检索算子,不能筛选:" + type); + } + + private static String requireText(JsonNode field, ReadModels.FilterItem item) { + String value = item == null ? null : item.getValue(); + if (value == null || value.trim().isEmpty()) { + throw fail(field, item == null ? "" : item.getKey(), "筛选值不能为空"); + } + return value.trim(); + } + + private static BigDecimal parseNumber(JsonNode field, String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + try { + return new BigDecimal(raw.trim()); + } catch (NumberFormatException e) { + throw fail(field, field.path("key").asText(), "不是数值:" + raw); + } + } + + private static LocalDate parseDate(JsonNode field, String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + try { + return LocalDate.parse(raw.trim()); + } catch (DateTimeParseException e) { + throw fail(field, field.path("key").asText(), "日期格式应为 yyyy-MM-dd:" + raw); + } + } + + private static BizException fail(JsonNode field, String key, String reason) { + return new BizException(ErrorCode.PARAM_ERROR, "筛选条件[" + key + "](" + + field.path("label").asText(field.path("type").asText()) + ")" + reason); + } +} diff --git a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java new file mode 100644 index 0000000..f85b27f --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java @@ -0,0 +1,55 @@ +package com.yunhai.formcenter.common; + +import com.yunhai.platform.utils.model.BoaModule; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。 + * + *

模块行有两种身份,必须分开取:

+ *
    + *
  • {@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);
  • + *
  • {@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位, + * 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。
  • + *
+ * + *

纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。

+ */ +public final class FormModuleKeys { + + private FormModuleKeys() { + } + + /** 授权页面路由集合(path 空的权限锚点不在内) */ + public static Set paths(List tree) { + Set result = new LinkedHashSet<>(); + collect(tree, true, result); + return result; + } + + /** 授权 customId 集合(path 空的权限锚点主要靠它表达) */ + public static Set customIds(List tree) { + Set result = new LinkedHashSet<>(); + collect(tree, false, result); + return result; + } + + private static void collect(List nodes, boolean wantPath, Set 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); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java b/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java index 4ba221a..3a3cec0 100644 --- a/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java +++ b/src/main/java/com/yunhai/formcenter/common/FormSchemaValidator.java @@ -382,6 +382,30 @@ public final class FormSchemaValidator { return result; } + /** + * 可筛选字段(key → 字段节点):{@link #searchableKeys} 的筛选候选口径, + * 比索引写入少一类——{@code upload/image} 排除在外。 + * + *

依据执行手册 §5 P28 裁决:契约 §2 只给三类算子(等值/区间/前缀模糊),附件没有算子, + * 索引侧"不写行只 warn"(T3 已落)、筛选候选侧"不进候选"(本轮)。两处同一个理由, + * 不能让前端拿到一个勾了 searchable 的附件字段、提交筛选后恒返回空集。

+ */ + public static Map filterableFields(String schemaJson) { + Map result = new LinkedHashMap<>(); + Map 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 NO_OPERATOR_TYPES = new LinkedHashSet<>(Arrays.asList("upload", "image")); + /** schema 里引用的字典 code({@code dict:{code}} 去掉了前缀),发布前据此查可解析性 */ public static List dictRefCodes(String schemaJson) { List codes = new ArrayList<>(); diff --git a/src/main/java/com/yunhai/formcenter/common/FormValueSchemaBuilder.java b/src/main/java/com/yunhai/formcenter/common/FormValueSchemaBuilder.java new file mode 100644 index 0000000..ba478cf --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/common/FormValueSchemaBuilder.java @@ -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 风格片段)。 + * + *

生成依据是契约 §3 的值形状表,逐类对照:文本类 {@code string}(带 maxLength/pattern)、 + * 数值 {@code number}(带声明过的 min/max)、日期 {@code string+format:date}、 + * 单选 {@code string+enum}、多选 {@code array(items=string+enum)}、 + * 附件 {@code array(items=object{id,fileName,key})}(一期值里没有 URL,故 properties 就这三件、 + * {@code additionalProperties:false})。字典引用字段在生成前必须已内联展开(契约 §2.1), + * enum 因此来自展开后的 options。

+ * + *

不做的事:不发明契约里没有的约束(不给 number 造 {@code multipleOf}、 + * 不给多选加 {@code uniqueItems});一期未放行的类型(address/user/users)不给形状—— + * 它们在发布校验就被拦下,出现在这里只会误导消费方按二期语义接线。

+ */ +public final class FormValueSchemaBuilder { + + private FormValueSchemaBuilder() { + } + + /** + * 生成该字段的 valueSchema 片段。 + * + * @param field 已内联展开 options 的字段节点 + * @return 片段;一期未放行的类型返回 null(调用方不写该键) + */ + public static ObjectNode valueSchema(JsonNode field) { + if (field == null) { + return null; + } + ObjectMapper mapper = FormSchemaValidator.mapper(); + String type = field.path("type").asText(); + ObjectNode node = mapper.createObjectNode(); + switch (type) { + case "text": + case "textarea": + case "phone": + node.put("type", "string"); + putIfNumber(node, "maxLength", field.get("maxLength")); + putIfText(node, "pattern", field.get("pattern")); + return node; + case "number": + node.put("type", "number"); + putIfNumber(node, "minimum", field.get("min")); + putIfNumber(node, "maximum", field.get("max")); + return node; + case "date": + node.put("type", "string"); + node.put("format", "date"); + return node; + case "select": + case "radio": + node.put("type", "string"); + putEnum(node, field.get("options")); + return node; + case "checkbox": + case "multiselect": { + node.put("type", "array"); + ObjectNode item = mapper.createObjectNode(); + item.put("type", "string"); + putEnum(item, field.get("options")); + node.set("items", item); + return node; + } + case "upload": + case "image": { + node.put("type", "array"); + ObjectNode file = mapper.createObjectNode(); + file.put("type", "object"); + ObjectNode properties = mapper.createObjectNode(); + properties.set("id", typed(mapper, "integer")); + properties.set("fileName", typed(mapper, "string")); + properties.set("key", typed(mapper, "string")); + file.set("properties", properties); + ArrayNode required = file.putArray("required"); + required.add("id"); + required.add("fileName"); + required.add("key"); + file.put("additionalProperties", false); + node.set("items", file); + putIfNumber(node, "maxItems", field.get("maxFiles")); + return node; + } + default: + // address/user/users(二期)与未知类型:不给机读形状,也不瞎造 + return null; + } + } + + /** + * 整棵 schema 加工(契约 §4 的 groups 形状):每个字段挂上 valueSchema,返回深拷贝, + * 不改写入参(版本行 schema 原文逐字节冻结,V7)。 + */ + public static JsonNode withValueSchemas(JsonNode root) { + ObjectMapper mapper = FormSchemaValidator.mapper(); + ObjectNode copy = root == null || !root.isObject() + ? mapper.createObjectNode() : (ObjectNode) root.deepCopy(); + for (JsonNode group : copy.path("groups")) { + if (!group.isObject()) { + continue; + } + for (JsonNode field : group.path("fields")) { + if (!field.isObject()) { + continue; + } + ObjectNode valueSchema = valueSchema(field); + if (valueSchema != null) { + ((ObjectNode) field).set("valueSchema", valueSchema); + } + } + } + return copy; + } + + private static ObjectNode typed(ObjectMapper mapper, String type) { + return mapper.createObjectNode().put("type", type); + } + + private static void putEnum(ObjectNode node, JsonNode options) { + if (options == null || !options.isArray() || options.size() == 0) { + return; + } + ArrayNode enumArray = node.putArray("enum"); + for (JsonNode option : options) { + String value = option.path("value").asText(null); + if (value != null) { + enumArray.add(value); + } + } + if (enumArray.size() == 0) { + node.remove("enum"); + } + } + + private static void putIfNumber(ObjectNode node, String key, JsonNode value) { + if (value != null && value.isNumber()) { + node.set(key, value); + } + } + + private static void putIfText(ObjectNode node, String key, JsonNode value) { + if (value != null && value.isTextual() && !value.asText().isEmpty()) { + node.put(key, value.asText()); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java b/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java index c2a571a..e0bb73e 100644 --- a/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java +++ b/src/main/java/com/yunhai/formcenter/common/FormValueValidator.java @@ -92,14 +92,19 @@ public final class FormValueValidator { return valuesJson; } - /** 提交值里引用的附件 id(服务层据此做同校归属校验,防把别人的 key 塞进自己的答卷) */ - public static List attachmentFileIds(String valuesJson, String schemaJson) { - List ids = new ArrayList<>(); + /** + * 值文档里引用的附件对象(upload/image 字段数组的每个 {@code {id,fileName,key}} 元素)。 + * + *

返回整对象而不只返回 id:服务层的归属校验除"id 必须存在且属本校"外,还要比对 + * {@code key==form_file.store_key}(P34 裁决)——只给 id 就得把值文档再走一遍。

+ */ + public static List attachments(String valuesJson, String schemaJson) { + List files = new ArrayList<>(); Map 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 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; } // ==================== 分派 ==================== diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java index 3d05121..165a166 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java @@ -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 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 的接口侧断言点):只有显式声明 searchable 的字段才进候选, * 取当前发布版本的 schema;未发布时返回空列表(草稿字段不参与筛选)。 + * 附件类(upload/image)即便勾了 searchable 也排除——契约 §2 没给它检索算子(P28)。 */ @GetMapping("/filterFields") public List 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 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 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) { diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java index 6e69f21..a753318 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerAdminController.java @@ -17,11 +17,14 @@ import java.util.List; * 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。 * *

这里没有编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用" - * 的实现形态就是不存在这些端点,加上管理权守卫给出的 403)。改状态同理:填表人在接口层即被拒(V14)。

- * - *

也不提供列表/详情读接口:那四条读出路径(列表/详情/统计/导出)必须共用同一个 scope 构造器 - * (方案 §6.4 红线,V27/V30),归 T4 一次建成,避免本轮先写一个没有范围过滤的读接口、T4 再补一遍。

+ * 的实现形态就是不存在这些端点,加上数据管理模块行守卫给出的 403)。改状态同理: + * 填表人在接口层即被拒(V14)。

* + *

两层权限(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data}, + * 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次行级数据范围 + * (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动, + * 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。

+ *

{@code schId} 参数只是超管跨校的目标域(校级传别的值即拒,同 {@link FormAdminController} 口径); * 答卷定位按 {@code (域, uuid)},跨校答卷查不到。

*/ @@ -33,13 +36,13 @@ public class FormAnswerAdminController { private final FormAdminGuard adminGuard; private final AnswerAdminService adminService; - /** 编辑值文档:按该答卷钉住的 version 校验(V8 同族),写 answer_op_log,索引同步重建 */ + /** 编辑值文档:按该答卷钉住的 version 校验(P25 坐标=form_uuid+version),写 op_log、索引重建(V29) */ @PostMapping("/edit") public void edit(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody AnswerModels.EditRequest body) { - Long domain = adminGuard.requireAdmin(user, schId); - adminService.edit(domain, user.getId(), body); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + adminService.edit(user, domain, body); } /** 软删(唯一删除形态,无物理删除接口——V30);同步删索引行并释放限次名额 */ @@ -47,8 +50,8 @@ public class FormAnswerAdminController { public void softDelete(BoaUser user, @RequestParam(required = false) Long schId, @RequestParam("answerId") String answerId) { - Long domain = adminGuard.requireAdmin(user, schId); - adminService.softDelete(domain, user.getId(), answerId); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + adminService.softDelete(user, domain, answerId); } /** 改状态:填表人不可用(V14);写 status_log,系统内无任何转移规则校验(V15) */ @@ -56,17 +59,17 @@ public class FormAnswerAdminController { public void changeStatus(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody AnswerModels.ChangeStatusRequest body) { - Long domain = adminGuard.requireAdmin(user, schId); - adminService.changeStatus(domain, user.getId(), body); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + adminService.changeStatus(user, domain, body); } - /** 批量软删:逐条单条校验与留痕,每条独立事务,结果逐条返回 */ + /** 批量软删:逐条单条校验与留痕(V39),每条独立事务,逐条判范围、逐条返回结果 */ @PostMapping("/batch/softDelete") public List 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 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); } } diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java new file mode 100644 index 0000000..d8a7932 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java @@ -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 四条读出路径的前两条)。 + * + *

权限两层各管一件事,缺一不可(方案 §6.1 三层模型的第②③层):

+ *
    + *
  • 能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data}, + * 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");
  • + *
  • 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId, + * 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。
  • + *
+ * + *

所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校—— + * 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。

+ * + *

身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径); + * 响应只出现 uuid(V5)。列表是两段式的对外形态、导出是流式 xlsx(不走统一 JSON 信封, + * 见 {@link #export} 注释)。

+ */ +@Slf4j +@RestController +@RequestMapping("/api/form/admin/data") +@RequiredArgsConstructor +public class FormAnswerReadController { + + private final FormAdminGuard adminGuard; + private final AnswerReadService readService; + private final AnswerStatsService statsService; + private final AnswerExportService exportService; + + /** 两段式列表(keyset 游标对外是 uuid,排序恒为提交时间序) */ + @PostMapping("/list") + public ReadModels.ListView list(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody ReadModels.ListRequest body) { + requireFormUuid(body.getFormUuid()); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + return readService.list(user, domain, body); + } + + /** 详情(uuid 寻址):行 + 钉住版本的内联展开 schema;不在范围内=FORBIDDEN(与列表同一构造器判) */ + @GetMapping("/detail") + public ReadModels.DetailView detail(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestParam("answerId") String answerId) { + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + return readService.detail(user, domain, answerId); + } + + /** 筛选候选列(V3 + P28:searchable 且有检索算子的字段,附件类不进候选) */ + @GetMapping("/filterFields") + public List 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 流式同步响应)。不套 JSON 信封:这是文件流,{@code ApiResponseAdvice} 对 + * 已提交的双进制响应不介入(返回类型 void、直接写 OutputStream),失败时仍由全局异常处理回到信封。 + * + *

响应头里文件名只用 code(URL-safe 子集,P16 定稿)拼,避免中文文件名在各浏览器上的编码分支; + * 行数=该身份范围下的总数(V37),受范围约束由 {@code AnswerExportService} 与读路径同源保证。

+ */ + @PostMapping("/export") + public void export(BoaUser user, + @RequestParam(required = false) Long schId, + @RequestBody ReadModels.QueryRequest body, + HttpServletResponse response) throws IOException { + requireFormUuid(body.getFormUuid()); + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + String fileName = "form-answers.xlsx"; + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Content-Disposition", + "attachment; filename=" + fileName + "; filename*=UTF-8''" + encode(fileName)); + try (OutputStream out = response.getOutputStream()) { + long written = exportService.export(user, domain, body, out); + log.info("[导出] schId={} formUuid={} 行数={}", domain, body.getFormUuid(), written); + } + } + + private static void requireFormUuid(String formUuid) { + if (formUuid == null || formUuid.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空(数据面按表单 uuid 寻址,契约 §1)"); + } + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return value; + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java new file mode 100644 index 0000000..deff4d6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java @@ -0,0 +1,48 @@ +package com.yunhai.formcenter.controller.oapi; + +import com.yunhai.formcenter.model.OapiModels; +import com.yunhai.formcenter.service.OapiFormService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的 + * {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。 + * + *
+ * GET /oapi/form/schema?schId=&code=[&appCode=]  → data=内联 options + valueSchema 的机读 schema(契约 §4)
+ * GET /oapi/form/answer?schId=&answerId=         → data=钉住版本的 schema 引用 + 值文档(契约 §9)
+ * 
+ * + *

信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调 + * (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。

+ * + *

{@code appCode} 是 {@code /schema} 的可选参数(契约 §9 只写 schId+code): + * 不传=form-center(中心自己的表单),传=取该快应用归属下的同 code 表单。 + * 加它是为了让快应用表单也能被对外读到,且不改变缺省行为——登记执行手册 §5 问题单。

+ */ +@RestController +@RequestMapping("/oapi/form") +@RequiredArgsConstructor +public class OapiFormController { + + private final OapiFormService formService; + + /** 机读 schema(内联 options + valueSchema) */ + @GetMapping("/schema") + public OapiModels.SchemaView schema(@RequestParam("schId") Long schId, + @RequestParam("code") String code, + @RequestParam(value = "appCode", required = false) String appCode) { + return formService.schema(schId, appCode, code); + } + + /** 单条答卷(钉住版本 schema 引用 + 值文档) */ + @GetMapping("/answer") + public OapiModels.AnswerView answer(@RequestParam("schId") Long schId, + @RequestParam("answerId") String answerId) { + return formService.answer(schId, answerId); + } +} diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java index 3160d04..f420952 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Answer.java +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -14,9 +14,10 @@ import javax.persistence.UniqueConstraint; * *

三条钉死的事实:

*
    - *
  • 版本钉住:{@code (sch_id, app_code, code, version)} 是提交时解析到的版本冗余下来 - * (方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20), - * 在途提交天然按旧版落地,编辑/详情/导出也都按这一行列语义走。
  • + *
  • 版本钉住:{@code form_uuid} + {@code version} 是提交时解析到的版本坐标 + * (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与 + * 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20), + * 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。
  • *
  • 身份三键(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、 * {@code sch_id}=会话校,全部会话推导不可外部传入(契约 §5、V17)。
  • *
  • 幂等键是库级唯一:防双击/重放只有 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; + /** + * 提交时解析命中的那张表单的 uuid(P25 裁决:钉版溯源的精确坐标)。 + * + *

    为什么不能只靠 {@code (sch_id, app_code, code, version)}:那组坐标不含"表单归属校", + * 平台级表单(sch_id=0)被本校后建的同 {@code (app_code, code)} 表单遮蔽后就出现歧义窗口—— + * 遮蔽规则会把解析指向本校表单,版本号若恰好对得上,历史答卷会被静默按错 schema + * 校验/重建索引(比查不到更糟)。按 form_uuid → form(uk_form_uuid) → 版本行取数没有这个窗口: + * uuid 跨版本不变(契约 §1),遮蔽与升版都不影响它指向的表单。

    + */ + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + @Column(name = "sch_id", nullable = false, updatable = false) private Long schId; diff --git a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java index a300d7f..ee698cd 100644 --- a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java +++ b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java @@ -59,6 +59,18 @@ public final class FormEnums { } return false; } + + /** 对外下发用枚举名(契约 §4 的 {@code "audience":"STUDENT"}),不外露数字 */ + public static String nameOf(Integer code) { + if (code != null) { + for (Audience a : values()) { + if (a.code == code) { + return a.name(); + } + } + } + return null; + } } /** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */ @@ -87,4 +99,38 @@ public final class FormEnums { public enum LimitScope { SUBJECT, SUBMITTER } + + /** + * 数据范围(契约 §8 / 方案 §6.4):闭集四枚举,一期契约即含全部四值—— + * 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。 + * + *

    {@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK): + * 枚举里有、一期不可选(V28),其实现器只是占位。

    + */ + public enum DataScope { + ALL("全部"), + TEACHING_CLASS("任教班级"), + SELF_SUBMITTED("仅本人提交"), + ABOUT_ME("关于我"); + + private final String label; + + DataScope(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + /** 枚举名解析(对外只暴露名字,数字序号不外露——加值时不破坏兼容) */ + public static DataScope of(String name) { + for (DataScope scope : values()) { + if (scope.name().equalsIgnoreCase(name == null ? null : name.trim())) { + return scope; + } + } + return null; + } + } } diff --git a/src/main/java/com/yunhai/formcenter/model/OapiModels.java b/src/main/java/com/yunhai/formcenter/model/OapiModels.java new file mode 100644 index 0000000..a563c43 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/OapiModels.java @@ -0,0 +1,72 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import lombok.Setter; + +/** + * 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。 + * + *

    信封 {@code {code,msg,data}} 与 {@code code=0} 由平台 starter 的统一响应链负责(同 flow-center + * {@code /oapi/flow/**} 先例),本层只交 data 载荷;因此这里不出现任何"未找到"专用码—— + * 未找到按契约 §6 定稿映射走 {@code PARAM_ERROR(2)} + msg。

    + * + *

    不外露的三样:库内 {@code id}(契约 §1/V5)、字典本身({@code options} 一律内联展开,契约 §2.1)、 + * 附件绝对 URL(只有值里的 {@code {id,fileName,key}},V24)。

    + */ +public final class OapiModels { + + private OapiModels() { + } + + /** {@code GET /oapi/form/schema} 的 data:机读 schema(内联 options + 每字段 valueSchema) */ + @Getter + @Setter + public static class SchemaView { + private String formUuid; + private Integer version; + private String code; + private String appCode; + /** STUDENT / TEACHER(枚举名下发,不外露数字,契约 §4 示例) */ + private String audience; + /** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */ + private JsonNode groups; + } + + /** {@code GET /oapi/form/answer} 的 data:钉住版本的 schema 引用 + 值文档(契约 §9) */ + @Getter + @Setter + public static class AnswerView { + private String answerId; + private String formUuid; + private String appCode; + private String code; + /** 该答卷钉住的版本(不随升版改变) */ + private Integer version; + private String status; + private Integer submitterId; + private Integer subjectId; + private String answerNo; + private String submitTime; + /** 值文档原样(三态空值/已删字段的降级由消费方按 schema 判,契约 §3) */ + private JsonNode values; + /** 钉住版本的 schema 引用(消费方要"当时那张表"就按这组坐标取,见 {@link #groups}) */ + private SchemaRef schemaRef; + /** + * 钉住版本的内联展开 schema(同 §4 形状但不含 valueSchema)。 + * 为什么带上:§9 的 schema 端点下发的是当前发布版本,答卷钉的可能是历史版本—— + * 只给引用不给内容,消费方拿不到"当时那张表",快照语义就落空(契约 §9"快照请消费方值拷贝")。 + */ + private JsonNode groups; + } + + /** schema 坐标(不含库内 id) */ + @Getter + @Setter + public static class SchemaRef { + private String formUuid; + private String appCode; + private String code; + private Integer version; + } +} diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java new file mode 100644 index 0000000..67814eb --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java @@ -0,0 +1,194 @@ +package com.yunhai.formcenter.model; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.formcenter.common.FormFilterOperators; +import com.yunhai.formcenter.enums.FormEnums.DataScope; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。 + * + *

    三条对外纪律:

    + *
      + *
    • 游标也是 uuid:keyset 的游标值对外是 {@code cursor}=上一页末行答卷的 uuid, + * 服务端按 {@code uk_answer_uuid} 换成库内 id 再比——自增 id 不出现在任何响应外层(契约 §1/V5)。 + * 代价是每页多一次索引点查,比把 id 泄露进 URL 便宜得多;
    • + *
    • 排序列不外露:读请求没有"按某列排序"的参数,一期恒为提交时间序(方案 §5.5 铁律②), + * 只有方向;
    • + *
    • 值文档原样回传:三态空值(无值/无 key/已删字段)的降级显示由前端做(契约 §3), + * 后端不改写、不补占位。
    • + *
    + */ +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 filters; + /** 仅统计接口消费:按哪个 searchable 字段分组,可空=只出总数与按状态 */ + private String groupByField; + } + + /** + * 单个筛选条件。算子不是调用方给的:契约 §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 rows = new ArrayList<>(); + /** 传给下一页请求的 cursor;当页为空即 null(到底) */ + private String nextCursor; + private Integer size; + /** 生效范围(枚举名,便于前端与验收断言对照;不承诺 UI 文案) */ + private Set 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 byStatus = new ArrayList<>(); + /** 按字段分组结果;未请求分组时为空列表 */ + private List 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 items = new ArrayList<>(); + } + + /** 筛选候选列(V3/P28:只有 searchable 且有算子的字段才出现在这里) */ + @Getter + @Setter + public static class FilterFieldView { + private String key; + private String label; + private String type; + /** EQ(等值)/ RANGE(区间)/ PREFIX(前缀模糊)——由 type 推导,前端按它出控件 */ + private String operator; + } + + /** + * 读路径内部查询规格(服务端推导,不从请求体绑定):租户域 + 表单坐标 + 范围 + 筛选 + 游标。 + * {@link AnswerScopeQueryBuilder} 的入参,四条读出路径与管理动作行级把关共用同一个形状。 + * + *

    {@code filters} 装的是已按字段类型收敛过的 {@link FormFilterOperators.Spec}—— + * 请求里的裸条件(字符串三元组)在服务层解析成 Spec,构造器因此不认识 schema、也不给注入面。

    + */ + @Getter + @Setter + public static class Query { + private Long schId; + private String appCode; + private String code; + private Set scopes; + private Integer userId; + private String status; + private List filters; + /** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */ + private Long cursorId; + private boolean ascending; + private int size = 20; + /** 行级 uuid 限定(详情/管理动作的"这条在不在范围内"判定用),可空 */ + private String answerUuid; + + public List filtersOrEmpty() { + return filters == null ? new ArrayList<>() : filters; + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java index ba10a80..8c56140 100644 --- a/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerIndexRepository.java @@ -17,6 +17,10 @@ public interface AnswerIndexRepository extends JpaRepository List findByAnswerIdOrderByIdAsc(Long answerId); + /** 该答卷已有索引行的字段(升版补建去重用,方案 §5.2 索引行生命周期) */ + @Query("select x.fieldKey from AnswerIndex x where x.answerId = :answerId") + List findFieldKeysByAnswerId(@Param("answerId") Long answerId); + /** 重建/软删清理:bulk delete(方案 §7.7 全量覆盖式保存用 @Modifying) */ @Modifying @Query("delete from AnswerIndex x where x.answerId = :answerId") diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java index 3c1b428..25dc4ff 100644 --- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java @@ -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 红线), - * 本轮只放提交与管理操作必需的查询——不提供任何物理删除方法(P22/V30)。 + * T4 的检索/统计/导出不在这里写查询——那四条读出路径必须共用同一个 scope 构造器 + * (方案 §6.4 红线),实现落在 {@code AnswerScopeQueryBuilder},本仓库只提供它取不到形状的 + * 两段式第二段({@link #findLiveByIds})与提交/管理操作的定位方法。 + * answer 数据不提供任何物理删除方法(P22/V30;{@code FillQuotaRepository.releaseQuota} + * 的删除是"释放名额",方案 §5.4 点名行为,不属答卷数据删除)。 */ public interface AnswerRepository extends JpaRepository { @@ -20,6 +25,16 @@ public interface AnswerRepository extends JpaRepository { Optional findByUuidAndSchId(String uuid, Long schId); + /** + * 两段式列表的第二段(方案 §5.5 铁律①):按当页 id 批量取整文档。 + * 第一段({@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 findLiveByIds(@Param("schId") Long schId, @Param("ids") Collection ids); + /** * 报名号当日序号:统计该 (校, app_code, code) 下以 {@code {code}-{YYYYMMDD}-} 开头的行数(含软删行—— * 软删不回收号,否则同日重填会出现两个相同报名号)。前缀由服务端拼接,参数绑定进 JPQL。 @@ -31,4 +46,15 @@ public interface AnswerRepository extends JpaRepository { /** 测试与审计取证:某表单下的全部答卷(按 id 升序) */ List findBySchIdAndAppCodeAndCodeOrderByIdAsc(Long schId, String appCode, String code); + + /** + * 升版补建索引行的分批扫描({@code AnswerIndexService#backfillAfterPublish}): + * 按 {@code form_uuid}(P25 的精确坐标)取未删答卷、id 游标推进、批大小由 Pageable 下推。 + * 这是唯一允许整页取 {@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 findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor, + Pageable pageable); } diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java index e8f5de8..b2e4a63 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java @@ -21,6 +21,14 @@ public interface FormRepository extends JpaRepository { Optional
    findByUuidAndSchId(String uuid, Long schId); + /** + * 按 uuid 精确取头表(P25:数据面 {@code answer.form_uuid} 的溯源入口,uk_form_uuid 唯一)。 + * 不带 sch_id 条件、也不过滤 is_deleted——答卷的钉版坐标是全局 uuid, + * 归属校由 {@code answer.sch_id} 自己保证;软删表单的历史答卷仍可溯源(P32/方案 §5.4)。 + * 租户域复核在调用方({@code FormDefService#readDomainForm})。 + */ + Optional findByUuid(String uuid); + /** * 管理端列表:本校未删表单(软删行不进列表,方案 §5.4)。 * app_code/status 过滤在应用层做——一所学校的表单量级是个位数到几百, @@ -35,14 +43,4 @@ public interface FormRepository extends JpaRepository { List findResolveCandidates(@Param("code") String code, @Param("appCode") String appCode, @Param("schIds") Collection schIds); - - /** - * 数据面取域(T3 答卷溯源):同一条遮蔽规则,但不排除软删表单—— - * 表单只有软删、没有物理删除(方案 §5.4),其历史答卷仍要可按钉住版本校验与溯源, - * 否则"删掉表单"会连带把存量数据变成无法管理的孤儿。 - */ - @Query("select f from Form f where f.code = :code and f.appCode = :appCode and f.schId in (:schIds)") - List findDataDomainCandidates(@Param("code") String code, - @Param("appCode") String appCode, - @Param("schIds") Collection schIds); } diff --git a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java new file mode 100644 index 0000000..27dc31d --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java @@ -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; + +/** + * 范围实现器:关于我({@code subject_id}=会话用户)。 + * + *

    与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是 + * 关于自己的数据,包含老师代填的那一条——老师代填时 {@code submitter_id} 是老师、 + * {@code subject_id} 是学生,按本范围才可见(V18)。

    + */ +@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 root, Integer userId) { + return cb.equal(root.get("subjectId"), userId); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java new file mode 100644 index 0000000..ece1ca3 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java @@ -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; + +/** 范围实现器:全部——不加行级条件(契约 §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 root, Integer userId) { + return cb.conjunction(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java index 67b1ff6..64d2ba0 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java @@ -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 生命周期表"谁碰、怎么碰"那一列的落地)。 * *
      - *
    • 编辑:仅管理员,原地修正值文档;按该答卷提交时钉住的 version 校验与渲染 - * (V8 同族——新 schema 的字段不会凭空出现在旧数据上,旧 schema 已删的字段也不会被新校验误拒); - * 写 {@code answer_op_log},索引同步重建。不改状态、不改身份、不看截止时间。
    • - *
    • 软删:唯一删除形态(无物理删除接口,V30/P22);同步删 {@code answer_index} 行 - * (方案 §5.2)+ 释放 fill_quota 名额(管理员删错单后学生可重填,方案 §5.4 点名场景); - * 留痕表不动——痕迹是审计资产。
    • + *
    • 编辑:仅管理员,原地修正值文档;按该答卷钉住的版本校验与渲染 + * (P25:坐标={@code answer.form_uuid + version},V8 同族——新 schema 的字段不会凭空出现在旧数据上, + * 旧 schema 已删的字段也不会被新校验误拒);写 {@code answer_op_log},索引同步重建。 + * 不改状态、不改身份、不看截止时间(P32)。
    • + *
    • 软删:唯一删除形态(无物理删除接口,V30);同步删 {@code answer_index} 行(方案 §5.2) + * + 释放 fill_quota 名额(方案 §5.4 点名场景);留痕表不动——痕迹是审计资产。
    • *
    • 改状态:只写 {@code status_log},系统内不存在任何转移规则校验(V15); * 填表人在接口层即被拒(V14)。目标状态必须是该表单状态定义里的未废弃 code。
    • - *
    • 批量:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务, - * 一条失败不回滚其它条,结果逐条返回。这是"批量与单条完全一致"的实现形态。
    • + *
    • 批量:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务,一条失败不回滚其它条。 + * 批量同样受数据范围约束(V39 的范围半边):范围检查在每条的执行体里, + * 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。
    • *
    * - *

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

    + *

    两层权限都过(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard}, + * 决定"能不能用数据管理接口"),每条答卷再过一次行级范围——判据是四条读出路径共用的 + * {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此列表里看不见的答卷, + * 编辑/软删/改状态也做不了:教师即便被授权了数据管理模块行,也只能动自己范围内的行。

    */ @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 batchSoftDelete(Long schId, Integer operatorId, + public List batchSoftDelete(BoaUser operator, Long schId, AnswerModels.BatchRequest req) { List ids = requireBatchIds(req.getAnswerIds()); List results = new ArrayList<>(); for (String answerUuid : ids) { results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult( - tx -> doSoftDelete(schId, operatorId, answerUuid, true)))); + tx -> doSoftDelete(operator, schId, answerUuid, true)))); } return results; } - public List batchChangeStatus(Long schId, Integer operatorId, + public List batchChangeStatus(BoaUser operator, Long schId, AnswerModels.BatchRequest req) { List ids = requireBatchIds(req.getAnswerIds()); List 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 的落点)。 + * + *

    顺序是刻意的:先按 {@code (域, uuid)} 定位(跨校答卷查不到,T3 已有的第一道), + * 再用四条读出路径共用的范围构造器判"这条在不在你的数据范围内"。 + * 判定用 SQL({@link AnswerScopeQueryBuilder#visible})而不是在 Java 里比字段—— + * 与列表口径必然一致,因此"列表能看见的"与"能改的"是同一个集合,不存在两套判据漂移。

    + */ + 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()); } } - } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerExportService.java b/src/main/java/com/yunhai/formcenter/service/AnswerExportService.java new file mode 100644 index 0000000..3d4d023 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerExportService.java @@ -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):范围恒为单表单 + 可选筛选,流式分批逐批写 easyexcel, + * 同步响应(不设行数上限、不做异步导出/下载中心——真实规模 1 千~2 万行级,没必要)。 + * + *

    三条纪律:

    + *
      + *
    • 受数据范围约束:查询规格与谓词都来自四读出路径共用的 {@link AnswerScopeQueryBuilder} + * (与 {@link #stats} 同源,见类内实现),所以"导出 ⊆ 该身份列表可见集"是结构性成立的, + * 不是靠导出侧再判一遍;
    • + *
    • 行数=该范围下的总数(不是列表当页):分批迭代靠 keyset 游标推进(每批 + * {@value #BATCH_SIZE} 行),全程不载入全量、也不 {@code select values} 做扫描;
    • + *
    • 内容按各行钉住版本渲染:选项列显 label(按该版本 options 定义序,契约 §3)、 + * 附件列显 fileName(值里本来就只有 {@code {id,fileName,key}},不拼 URL——V24)。
    • + *
    + */ +@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 currentFields = fieldsByVersion(form, form.getCurrentVersion()); + List 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> versionFields = new HashMap<>(); + try { + while (true) { + List ids = queryBuilder.pageIds(query); + if (ids.isEmpty()) { + break; + } + Map loaded = new HashMap<>(); + for (Answer answer : answerRepository.findLiveByIds(domainSchId, ids)) { + loaded.put(answer.getId(), answer); + } + List> 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> head(List columnKeys, Map currentFields) { + List> 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 rowOf(Answer answer, List columnKeys, Map pinnedFields) { + List 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 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 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 optionLabelsInDefinitionOrder(JsonNode field, JsonNode value) { + List labels = new ArrayList<>(); + List 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 textList(JsonNode value) { + List texts = new ArrayList<>(); + for (JsonNode item : value) { + texts.add(item.asText()); + } + return texts; + } + + private static String join(List parts) { + return String.join(SEPARATOR, parts); + } + + private static List single(String title) { + List column = new ArrayList<>(1); + column.add(title); + return column; + } + + // ==================== 版本 schema 缓存(一次导出内按版本号复用) ==================== + + /** 该答卷钉住版本的字段表(options 已内联展开,才能把 value 回查成 label) */ + private Map fieldsForVersion(Form form, Answer answer, + Map> memo) { + Map cached = memo.get(answer.getVersion()); + if (cached != null) { + return cached; + } + Map fields = fieldsByVersion(form, answer.getVersion()); + memo.put(answer.getVersion(), fields); + return fields; + } + + /** + * 指定版本的字段表;版本行缺失(历史脏数据)时返回空表——该行仍会被导出(身份列完整), + * 值列留空而不是让整次导出失败。 + */ + private Map 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<>(); + } + } + +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java b/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java index 059821e..5ada327 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerIndexService.java @@ -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)。 * - *

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

    + *

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

    * - *

    三个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})。 + *

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

    */ @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); + } + + /** + * 升版发布后给历史答卷补索引行——V9("新增 searchable 字段全程无 DDL,旧数据按新字段筛选正确") + * 的实现形态。 + * + *

    为什么必须补:提交时只按当时钉住版本的 searchable 声明写行,v2 才新声明的字段在 v1 + * 答卷上没有行,"旧数据按新字段筛"就落不了地。补建是只增不删的,与 V8"新版本删字段不清历史行" + * 同向,两条索引生命周期规则不冲突(方案 §5.2)。

    + * + *

    代价记账(登记执行手册 §5 问题单):发布时按 id 游标分批扫本表单未删答卷, + * 每批 {@value #BACKFILL_BATCH_SIZE} 份值文档,已有该 field_key 的行不重复插(幂等、可重入)。 + * 发布是低频管理动作,2 万份量级为秒级;一期不做异步补建半成品。

    + * + * @param formUuid 本次发布的表单(头表 uuid,P25 后答卷按它溯源) + * @param schemaJson 新版本 schema 原文 + * @return 补写的行数 + */ + public int backfillAfterPublish(String formUuid, String schemaJson) { + List keys = FormSchemaValidator.searchableKeys(schemaJson); + if (keys.isEmpty()) { + return 0; + } + int inserted = 0; + Long cursor = 0L; + while (true) { + List batch = answerRepository.findLiveByFormUuidAfterId( + formUuid, cursor, PageRequest.of(0, BACKFILL_BATCH_SIZE)); + if (batch.isEmpty()) { + break; + } + for (Answer answer : batch) { + Set held = new HashSet<>(indexRepository.findFieldKeysByAnswerId(answer.getId())); + List 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 rowsOf(Long answerId) { + return indexRepository.findByAnswerIdOrderByIdAsc(answerId); + } + + /** + * 按 schema 的 searchable 声明产出该答卷的索引行。 + * + * @param skipFieldKeys 已有行的字段集合(补建去重用;null=全量产出,提交/重建用) + */ + private List rowsFor(Answer answer, String schemaJson, Set skipFieldKeys) { + List rows = new ArrayList<>(); JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏"); Map 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 rowsOf(Long answerId) { - return indexRepository.findByAnswerIdOrderByIdAsc(answerId); + return rows; } private AnswerIndex row(Answer answer, String fieldKey, String vStr, BigDecimal vNum, LocalDate vDate) { diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java new file mode 100644 index 0000000..c05cdb8 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -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)。 + * + *

    与 {@link AnswerScopeQueryBuilder} 的分工:本类负责"把请求翻译成查询规格" + * (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。 + * 统计({@link AnswerStatsService})与导出({@link AnswerExportService})都调本类的 + * {@link #scopeQuery} 拿同一份规格——这就是"四条读出路径共用同一个 scope 构造器"的调用形态, + * 新增第五个读接口时也只能从这里取规格。

    + * + *

    三处纪律:列表走两段式(先取当页 id、再按 id 批量取值文档,铁律①); + * 翻页走 keyset 且游标对外是 uuid(铁律② + 契约 §1 id 不外露); + * 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。

    + */ +@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; + + // ==================== 规格构造(四路径共用) ==================== + + /** + * 会话身份 + 表单 + 请求 → 读路径查询规格。 + * + *

    范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来 + * (未登录等)即拒,不存在"默认全部"的分支。筛选条件按当前发布版本的 + * {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子), + * 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。

    + */ + 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 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 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 寻址):返回该答卷钉住版本的 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 且有算子的字段, + * 附按 type 收敛出的算子(前端据此出控件:等值下拉 / 区间双输入 / 前缀文本框)。 + * 未发布或无当前版本 → 空列表(草稿字段不参与筛选)。 + */ + public List filterFields(Long domainSchId, String formUuid) { + Form form = defService.readDomainForm(domainSchId, formUuid); + List result = new ArrayList<>(); + if (form.getCurrentVersion() == null) { + return result; + } + String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion()) + .versionRow().getSchemaJson(); + Map filterable = FormSchemaValidator.filterableFields(schemaJson); + Map 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 resolveFilters(Form form, List items) { + List 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 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 statusLabels(Form form) { + Map labels = new LinkedHashMap<>(); + for (FormStatusDefs.StatusDef def : FormStatusDefs.effective(form.getStatusDefs())) { + labels.put(def.getCode(), def.getLabel()); + } + return labels; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java new file mode 100644 index 0000000..45c4ba9 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java @@ -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; + +/** + * 数据范围实现器(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器, + * 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长, + * 接口隔离保证读路径不跟着改。 + * + *

    唯一出口约束:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词, + * 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上 + * 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。

    + */ +public interface AnswerScopeImplementor { + + /** 本实现器负责的范围值 */ + DataScope scope(); + + /** + * 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK) + * ——枚举里有、一期不可选(V28),故其实现器返回 false 且不参与解析结果。 + */ + boolean selectable(); + + /** + * 产出该范围对单条答卷的行级谓词。 + * + * @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准) + * @return 恒真({@code cb.conjunction()})=该范围不额外收窄行 + */ + Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId); +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java new file mode 100644 index 0000000..b4b5a2a --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java @@ -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; + +/** + * 数据范围查询构造器(方案 §6.4 红线 / 契约 §8 红线的唯一实现处): + * 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关, + * 全部调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、 + * 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。 + * + *

    一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)} + * → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询) + * → keyset 游标。

    + * + *

    四条铁律在本类的落点(方案 §5.5):

    + *
      + *
    1. 铁律①:{@link #pageIds} 只 {@code select a.id},不碰 values_json; + * 当页整文档由调用方按 id 批量取({@code AnswerRepository#findLiveByIds});
    2. + *
    3. 铁律②:游标条件恒落在 {@code id}(提交时间序,与索引同序),没有"按任意列排序"的入口, + * 也没有 offset;
    4. + *
    5. 铁律③:筛选落 {@code answer_index} 的 {@code (sch_id, code, field_key, v_*, answer_id)} + * 覆盖索引子查询;列表/计数落 {@code idx_answer_form_time};按状态分组落 {@code idx_answer_form};
    6. + *
    7. 铁律④:导出的分批迭代就是 {@link #pageIds} 的游标逐批前移,不存在一次取全量。
    8. + *
    + */ +@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 pageIds(ReadModels.Query q) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(Answer.class); + cq.select(root.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 cq = cb.createQuery(Long.class); + Root 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 groupByStatus(ReadModels.Query q) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Object[].class); + Root root = cq.from(Answer.class); + Expression 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 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}, + * "哪些答卷可见"用同一个范围构造器作子查询套上去——这就是"统计不是越权后门"的实现依据。 + * + * @param fieldKey 分组字段 + * @param column 该字段落地的索引列(由字段类型收敛而来) + * @return 值 → 答卷数(多值字段按 {@code count(distinct answer_id)},一答卷不因多值重复计数) + */ + public Map groupByField(ReadModels.Query q, String fieldKey, FormFilterOperators.Column column) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Object[].class); + Root ai = cq.from(AnswerIndex.class); + Expression value = comparablePath(ai, column); + + Subquery visibleAnswers = cq.subquery(Long.class); + Root answerRoot = visibleAnswers.from(Answer.class); + visibleAnswers.select(answerRoot.get("id")); + visibleAnswers.where(cb.and(predicates(visibleAnswers, cb, answerRoot, q).toArray(new Predicate[0]))); + + List 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.get("fieldKey"), fieldKey)); + indexPredicates.add(ai.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 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 cq = cb.createQuery(Long.class); + Root root = cq.from(Answer.class); + cq.select(root.get("id")); + cq.where(cb.and(predicates(cq, cb, root, probe).toArray(new Predicate[0]))); + List 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 predicates(AbstractQuery owner, CriteriaBuilder cb, Root root, + ReadModels.Query q) { + if (q.getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "读查询缺少租户域"); + } + List 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 root, ReadModels.Query q) { + Set 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 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 root, + ReadModels.Query q, FormFilterOperators.Spec filter) { + Subquery sub = owner.subquery(Long.class); + Root ai = sub.from(AnswerIndex.class); + List 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.get("fieldKey"), filter.getKey())); + if (filter.getColumn() == FormFilterOperators.Column.STR) { + Expression 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 column = ai.get("vNum"); + ps.add(cb.between(column, filter.getNumFrom(), filter.getNumTo())); + } else { + Expression column = ai.get("vDate"); + ps.add(cb.between(column, filter.getDateFrom(), filter.getDateTo())); + } + sub.select(ai.get("answerId")); + sub.where(cb.and(ps.toArray(new Predicate[0]))); + return root.get("id").in(sub); + } + + @SuppressWarnings("unchecked") + private Expression comparablePath(Root ai, FormFilterOperators.Column column) { + if (column == FormFilterOperators.Column.NUM) { + return (Expression) ai.get("vNum"); + } + if (column == FormFilterOperators.Column.DATE) { + return (Expression) ai.get("vDate"); + } + return (Expression) ai.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(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index 73e4cad..f5af0f9 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -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)。一次提交在这条流水线上依次过关: * *
    - * 钉版解析 → 截止 → 幂等命中 → subject 授权 → 值校验(按钉住版本) → 附件归属
    - *          → 限次占位(fill_quota 撞键即拒) → 落 answer → 写 answer_index(只写 searchable)
    + * 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本)
    + *          → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid)
    + *          → 写 answer_index(只写 searchable)
      * 
    * *

    为什么顺序是这样:

    @@ -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 同校归属 + P34 key 一致性)。 + * + *

    值里的 {@code {id,fileName,key}} 必须是同校上传接口返回的那三件原样: + * id 定位到本校的 form_file 行,key 必须等于该行的 store_key——否则"id 是中心侧可定位的附件行" + * 这个语义就只是半个(可以挂着别人的 key 渲染)。合法前端永远原样转存返回值,此校验不误伤正常流程。

    + */ private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) { - List 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); + } } } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerStatsService.java b/src/main/java/com/yunhai/formcenter/service/AnswerStatsService.java new file mode 100644 index 0000000..62a90f6 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AnswerStatsService.java @@ -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 字段分组, + * 聚合全部在接口侧(SQL)完成,前端不做聚合。 + * + *

    范围口径与列表同源:统计用的查询规格来自 {@link AnswerReadService#scopeQuery}、 + * 谓词来自 {@link AnswerScopeQueryBuilder}——按字段分组时"哪些答卷可见"是同一个构造器做的子查询。 + * 这条同源性就是 V27"受限身份的统计 ⊆ 其列表可见集"的实现依据;单独写一条 group by 就是越权后门。

    + * + *

    缓存(铁律④的失效策略):键={@code form_uuid + 当前发布 version + 范围签名 + 过滤条件}, + * 只在升版时换键失效,加短 TTL;不按数据变更失效——提交是高频事件,每次提交清缓存 + * 等于没有缓存(方案 §5.5 v1.2 修正)。代价=统计容忍 TTL 内的延迟,验收断言据此写 + * (新答卷落库后 TTL 内统计总数不变是预期行为,不是 bug)。

    + * + *

    缓存范围也收紧到有边界:只缓存无筛选条件的全量统计(方案原话"大表单全量分组统计加缓存"), + * 带筛选的组合键不缓存;条目数超上限整体清空,避免无界增长。键里含范围签名—— + * 否则"管理员算出的全校分布"会被教师命中,那就是缓存版本的越权后门。

    + */ +@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 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 statusBuckets(Form form, Map counts) { + Map labels = readService.statusLabels(form); + List items = new ArrayList<>(); + for (Map.Entry 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 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 optionLabels = optionLabels(field); + Map buckets = queryBuilder.groupByField(query, fieldKey, column); + List items = new ArrayList<>(); + for (Map.Entry 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 optionLabels(JsonNode field) { + Map 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 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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java new file mode 100644 index 0000000..262aa21 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java @@ -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 谓词。 + * + *

    解析规则(优先级):{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵}, + * 命中"全部"即收敛为全部;全不命中回落仅本人提交(宁紧勿松)。

    + * + *

    "多来源取最宽"在这里的实现是并集:一个用户可能同时有多个身份({@code roleIds} 里既有 + * 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义: + * 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。

    + * + *

    身份默认矩阵(契约 §8,行=roleId、列=表单面向人群):

    + *
    + *            面向学生(1)                     面向教师(2)
    + *   7/2  全部                            全部
    + *   1    任教班级 → 一期实现器缺席=仅本人提交   仅本人提交
    + *   3    关于我                           仅本人提交(该格契约写"不授填报模块行,进不来",
    + *                                         数据面按最保守的兜底值处理)
    + *   4/6/5 仅本人提交                       仅本人提交
    + * 
    + * + *

    与 {@link FormAdminGuard} 是两条正交的口径:守卫答"能不能进这个管理面"(授权模块行), + * 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求), + * 但一个是 path、一个是 customId,不要混用。

    + */ +@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 implementors; + + /** + * 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。 + * + * @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理 + */ + public Set 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 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 selectableScopes() { + Set 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 identityRoleIds(BoaUser user) { + List 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.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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java index 1a22552..0859452 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java +++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java @@ -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)。 * - *

    过渡态:方案 §6.1 第②层的权威载体是"授权模块集"(表单设计/数据管理/字典管理模块行), - * 那要等 T4 消费 GetUserMod 授权树后才能实现;在那之前按平台标准角色闸门把关, - * 口径=执行手册 §5 P21 裁决(T2 验收时对 flow-center 模板做的唯一一处收紧):

    + *

    T4 起口径=授权模块集(P21 裁决的到期替换:T2/T3 的 roleId∈{2,7} 硬编码整体移除、不保留回落)—— + * "中心管理员"就是被授权对应模块行的人,与平台其它子应用同构(左导航与后端把关用的是同一棵树, + * 因此不存在"菜单看得见、接口调不动"的两套口径):

    *
      - *
    • 超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN}):可跨校, - * 且是唯一能操作平台级行({@code sch_id=0})的身份;
    • - *
    • 校级:{@code roleId ∈ {2 校领导, 7 管理员}} 限本校—— - * 局领导(5) 不给管理权(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。
    • + *
    • 定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats}, + * 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。
    • + *
    • 超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、 + * 且是唯一能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定, + * 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。
    • *
    * - *

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

    + *

    与管理权正交的另一条线:数据面能看哪些行由契约 §8 的角色矩阵 + customId 决定 + * ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行, + * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。

    */ @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 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 { } /** - * 是否具备管理权(超管或校级管理员)。不含租户域核对—— + * 是否具备管理权(超管,或被授权定义管理/数据管理任一模块行)。不含租户域核对—— * 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。 + * + *

    现用途=契约 §5 的代填分支({@code subject_id} 能否指向他人):普通用户只能=自己, + * 有管理权者可代填(方案 §6.3/V17)。口径与 P21 过渡期的 roleId∈{2,7} 等宽(定义或数据管理), + * 判定依据换成授权模块集。

    */ 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 paths = authorizedPaths(user); + return paths.contains(MODULE_DATA_MANAGE) || paths.contains(MODULE_FORM_DESIGN); + } + + /** + * 同一条校验的"任一模块行命中即放行"形态。 + * + *

    现用途=统计:手册 §2.2 明示统计行"可并入数据管理,二选一",所以两种配法都算有统计权; + * 只有一个模块行要求的接口请用 {@link #requireAdmin}。

    + */ + 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 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 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; diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 4f1e828..7d8744a 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -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 { } /** - * 数据面解析答卷钉住的版本行(T3 编辑/软删/改状态用):按同一条校域遮蔽规则取头表, - * 再取该版本行;不校验生命周期——方案 §5.4"停用只断填报入口、数据读改接口不变", + * 数据面解析答卷钉住的版本行(编辑/软删/改状态 + T4 全部读路径): + * 按 {@code answer.form_uuid} → {@code form}(uk_form_uuid) → 该头表下 {@code version} 的行, + * 精确取数、不经遮蔽规则、不校验生命周期——方案 §5.4"停用只断填报入口、数据读改接口不变", * 升版期间的旧版答卷同样必须可管理。 * - *

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

    + *

    为什么按 form_uuid 而不是 (校, app_code, code, version)(P25 裁决,替代 T3 的回退启发式): + * 那组坐标不含"表单归属校",平台级表单被本校后建的同 {@code (app_code,code)} 表单遮蔽后会出现歧义窗口, + * 版本号恰好对上时历史答卷被静默按错 schema 校验/重建索引——比查不到更难发现。 + * uuid 跨版本不变(契约 §1),按它取数没有这个窗口;代价是历史行必须有该列, + * 本轮开发库答卷零行、无回填(系统未上线=零迁移成本窗口,H3 同款论证)。

    + * + *

    本方法不过滤 {@code form.is_deleted}:表单软删是唯一切除形态且无物理删除, + * 存量答卷的定义溯源不能因为表单被软删而断掉(P32/方案 §5.4)。

    */ - 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 + " 位(当前 " diff --git a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java new file mode 100644 index 0000000..cda853b --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java @@ -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; + +/** + * 会话用户在本应用下的已授权模块树来源(方案 §6.1 第②层,权限模型的唯一权威载体)。 + * + *

    T4 起两类判断都从这棵树出,共用同一个接口(执行手册 §5 P21/P30 裁决落地口径):

    + *
      + *
    • 管理权:定义管理/数据管理/统计/字典管理=被授权对应模块行(path 命中,手册 §2.2 表);
    • + *
    • 数据范围:{@code fc:scope:all} 授权锚点=这棵树里 path 空、customId 为该值的纯权限位 + * (契约 §8 扩展协议)。
    • + *
    + * + *

    做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。 + * 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责); + * 本地 IT 不启动 UserBase/CAS,注入桩即可断言权限矩阵本身,真实链路 T9 联调验。

    + * + *

    失败口径=fail-closed:不可达或异常按"无任何授权模块"处理(管理权被拒、范围回落到仅本人提交), + * 与契约 §8"全不命中=仅本人提交(宁紧勿松)"同向。

    + */ +public interface ModuleTreeSource { + + /** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */ + List authorizedModules(BoaUser user); +} diff --git a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java new file mode 100644 index 0000000..97e1d39 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java @@ -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 两端点)。 + * + *

    身份与租户:{@code schId} 是调用方声明的租户域(应用签名已核实调用方身份, + * 与 flow-center {@code /oapi/flow/**} 同一口径:"应用签名不替代谁能看"——消费方对自己侧的 + * 用户可见性负责,本层只保证不跨校取数:答卷按 {@code (uuid, sch_id)} 定位,跨校一律查不到, + * 表单按 resolve 的"会话校 ∪ 平台级、本校遮蔽"规则解析。

    + * + *

    与 CAS 读路径的分工:契约 §8 的行级数据范围矩阵挂在会话身份上,oapi 面没有会话用户 + * (签名方是应用),因此不走 {@link AnswerScopeQueryBuilder}——它给的是"这张表的结构"与"这一条答卷", + * 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死(一期对外只读单条与 schema, + * 不提供全量拉取)。这一条写清楚,避免后来人"顺手"加一个 /oapi/form/list。

    + */ +@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)"); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java new file mode 100644 index 0000000..dddf885 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java @@ -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 运行时链路)。 + * + *

    {@code schId} 传会话学校=把授权限定在该学校上下文(UserBase 侧只取该校子树内的真实归属组参与判定); + * 无学校归属的身份(区域组/根直属)传 null,按 UserBase 语义取全部归属组。

    + */ +@Slf4j +@Service +public class PlatformModuleTreeSource implements ModuleTreeSource { + + @Override + public List authorizedModules(BoaUser user) { + if (user == null || user.getId() == null) { + return Collections.emptyList(); + } + Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); + try { + List modules = YschBaseOpenApi.getUserModule(user.getId(), schId); + return modules == null ? Collections.emptyList() : modules; + } catch (Exception e) { + // fail-closed:取不到树就当没有授权——管理权拒、数据范围回落到仅本人提交(接口注释) + log.warn("[模块树] UserBase /oapi/user/modules 不可达,按无授权处理 userId={} schId={}:{}", + user.getId(), schId, e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/PlatformSubjectDirectory.java b/src/main/java/com/yunhai/formcenter/service/PlatformSubjectDirectory.java new file mode 100644 index 0000000..a45c260 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/PlatformSubjectDirectory.java @@ -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 裁决)。 + * + *

    失败口径见接口注释:不可达与查不到都返回 false(fail-closed),差异只落在日志上便于排查 + * ——flow-center {@code AssigneeResolver#requireUserName} 同款分档。

    + */ +@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 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; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java new file mode 100644 index 0000000..0936f24 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java @@ -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; + +/** + * 范围实现器:仅本人提交({@code submitter_id}=会话用户)。 + * + *

    这是契约 §8 的兜底值:"全不命中=仅本人提交(宁紧勿松)",也是教师×面向学生表单在 1.5 + * 之前的实际生效值(任教班级实现器缺席,方案 §6.4 表格里写明了要防"教师怎么只看到自己"被当 bug 报)。 + * 走 {@code idx_answer_submitter(sch_id, submitter_id)}。

    + */ +@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 root, Integer userId) { + return cb.equal(root.get("submitterId"), userId); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/SubjectDirectory.java b/src/main/java/com/yunhai/formcenter/service/SubjectDirectory.java new file mode 100644 index 0000000..4c79c16 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/SubjectDirectory.java @@ -0,0 +1,29 @@ +package com.yunhai.formcenter.service; + +/** + * 被代填人({@code answer.subject_id})的存在性与学校归属解析(执行手册 §5 P33 裁决)。 + * + *

    为什么要这层抽象:这个判断的事实源在 UserBase,不在本库。把它做成策略接口而不是 + * 在业务服务里直连 UserBase 库或自己拼签名,是为了——

    + *
      + *
    • 生产实现走平台标准签名调用({@code GET /oapi/user/byId?id=},签名四件套由 starter 装配的 + * 出站拦截器负责,本工程不私写签名逻辑);
    • + *
    • 本地/IT 环境不启动 UserBase 与 CAS,测试注入桩即可断言"查不到 / 跨校 → 拒"这条规则本身, + * 真实链路按裁决在 T9 联调验;
    • + *
    • V18 的"关于我的"视图依赖 subject id 指向真实存在的人,这道校验是它的前提。
    • + *
    + * + *

    口径:解析不出或不可达一律按"查不到"处理(fail-closed)。代填是低频动作 + * (管理员录一条数据),宁可让管理员重试一次,不可把不存在/他校的人挂进本校数据。

    + */ +public interface SubjectDirectory { + + /** + * 该 id 是否是本校真实存在的用户。 + * + * @param subjectId 被代填人 id + * @param schId 会话校(= {@code answer.sch_id},校域不变式) + * @return 存在且其学校归属包含 {@code schId} 才 true;查不到、跨校、UserBase 不可达都返回 false + */ + boolean existsInSchool(Integer subjectId, Long schId); +} diff --git a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java new file mode 100644 index 0000000..5bd2a95 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java @@ -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; + +/** + * 范围实现器:任教班级——1.5 占位(契约 §8 / 方案 §6.4)。 + * + *

    存在意义就是把 V28 的两半钉在代码里:枚举值在(一期契约即含四值,定了就不变), + * 但 {@link #selectable()} 为 false,{@code DataScopeService} 因此永远不会把它放进解析结果, + * 教师×面向学生表单在一期按矩阵注明的"实际生效=仅本人提交"落地。

    + * + *

    1.5 开放时的改动面就在本类:接 UserClass(原 BschClass)的任教关系/班级 SDK,把 + * {@code rowPredicate} 换成"submitter 的任教班级 ∩ 答卷班级列"或按 subject 的班级归属判定, + * {@code selectable()} 翻成 true——读路径与管理动作不用改(它们只认 {@link AnswerScopeQueryBuilder})。

    + */ +@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 root, Integer userId) { + // 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析 + throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)"); + } +} diff --git a/src/test/java/com/yunhai/formcenter/common/FormFilterOperatorsTest.java b/src/test/java/com/yunhai/formcenter/common/FormFilterOperatorsTest.java new file mode 100644 index 0000000..3f3c71e --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/common/FormFilterOperatorsTest.java @@ -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 附件无算子)。 + * + *

    这一层是"筛选注入面"的关口:请求只能给 {@code {key,value}} 或 {@code {key,from,to}}, + * 落到哪一列(v_str/v_num/v_date)、用哪种算子(等值/前缀/区间)由字段类型定, + * 且解析不出正确类型的值一律拒(不允许"字符串当数字比"或"日期走字典序"那种静默降级)。

    + */ +@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 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; + } +} diff --git a/src/test/java/com/yunhai/formcenter/common/FormValueSchemaBuilderTest.java b/src/test/java/com/yunhai/formcenter/common/FormValueSchemaBuilderTest.java new file mode 100644 index 0000000..d7b3a49 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/common/FormValueSchemaBuilderTest.java @@ -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 的"任取一类控件按示例构造答卷,后端接受"的机读侧依据)。 + * + *

    刻意只测形状、不测实现细节:类型、enum 来自内联 options、日期带 format、 + * 附件对象是 {@code {id,fileName,key}} 三件且 {@code additionalProperties:false}(值里绝不含 URL,V24), + * 契约没写的约束(multipleOf/uniqueItems)不出现——发明了就是对外承诺。

    + */ +@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); + } +} diff --git a/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java b/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java index 3ab0187..68a5b02 100644 --- a/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java +++ b/src/test/java/com/yunhai/formcenter/common/FormValueValidatorTest.java @@ -204,15 +204,18 @@ class FormValueValidatorTest { } @Test - @DisplayName("attachmentFileIds:只为附件类字段抽取引用 id(服务层据此做同校归属校验)") - void extractsAttachmentIdsOnlyFromAttachmentFields() { - List ids = FormValueValidator.attachmentFileIds( + @DisplayName("attachments:只为附件类字段抽出完整三件套(服务层按 id 定位行、再比对 key/fileName——P34)") + void extractsAttachmentItemsOnlyFromAttachmentFields() { + List 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(); } diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java index d15fb3f..87b591e 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java @@ -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)。

    */ @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 results = adminService.batchChangeStatus(SCH, 10001, withStatus(batch)); + List 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) { diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java index 54cdd26..b3635fd 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java @@ -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 交叉)。

    */ @SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) @DisplayName("AnswerService - 并发兜底(V32:唯一键才是并发下的裁判)") class AnswerConcurrencyIT extends AnswerTestSupport { diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java new file mode 100644 index 0000000..bb127fe --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java @@ -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;自验项 V37)。 + * + *

    V37 的四个断言点逐条对上:范围=单表单 + 可选筛选;行数与该数据范围下的总数一致 + * (不是列表当页);选项列显 label、附件列为文件名;受限身份导出 ⊆ 其可见集 + * (与 V27 同一构造器——这里断言的是"文件里真的只有那几行",而不是服务返回的计数)。

    + */ +@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> 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 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 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; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java new file mode 100644 index 0000000..e297446 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java @@ -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;自验项 V9 / V11 / V23 / V27 / V42 + + * V30 的读侧半边、V39 的范围半边、P25 遮蔽用例、方案 §5.5 铁律①②取证)。 + * + *

    数据集见 {@link ReadFixture}:两表单(面向学生/面向教师)× 学生/教师/局领导/管理员 × 软删混合。

    + * + *

    V27/V42 的硬断言方式:对每个受限身份,把"列表可见集"当作基准集合, + * 统计的总数与导出的行数必须等于该集合大小、且导出的 uuid 必须子于该集合—— + * 这是"四条读出路径共用同一个 scope 构造器"(契约 §8 红线)的可执行表达, + * 单独比数值大小不够(数值相等仍可能是不同的行)。

    + */ +@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 sqls = captureAnswerSqls(() -> readService.list(manager(), SCH, listRequest(rows.formAUuid))); + + List 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 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 identities = Arrays.asList(manager(), fixture.teacher1(), fixture.teacher2(), + fixture.student1(), fixture.student2(), fixture.bureauLeader()); + for (BoaUser user : identities) { + Set 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 后,旧答卷可按它筛选(全程无 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 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 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,且不按数据变更失效(新答卷落库后 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 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 表单遮蔽后,历史答卷仍按原 schema 校验/渲染(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) 表单并发布:遮蔽规则把填报入口指向本校表单 + 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 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 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 captureAnswerSqls(Runnable action) { + Logger sqlLogger = (Logger) LoggerFactory.getLogger("org.hibernate.SQL"); + Level previous = sqlLogger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + sqlLogger.addAppender(appender); + sqlLogger.setLevel(Level.DEBUG); + List 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); + } + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java index 9119b04..28313bd 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java @@ -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} 用双线程打真库取证。

    */ @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); diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java index d52edb3..b3ec4e5 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java @@ -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 扩读路径):会话用户构造 + 建表单并发布 + 提交入参拼装 + * + 两个外部事实源的测试桩。 * *

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

    + * + *

    两个外部事实源用桩顶掉({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS): + * {@link ModuleTreeSource}(授权模块集=P21 的管理权口径 + customId 范围升级位)与 + * {@link SubjectDirectory}(P33 的代填存在性)。生产实现是平台签名调用,接线在 T9 联调验; + * 本轮取证的是"规则本身对不对",桩把事实源钉住即可。

    */ 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 + 被授权数据管理/定义管理模块行)。 + * + *

    T4 起管理权看授权模块集(P21 口径),单有 roleId 不再等于能管理——所以"管理员"在测试里 + * 就是这两件事一起造。需要"管理员但没被授权"的反例时用 {@link #user(Integer, Long, Integer)} + * 再 {@code moduleTree.clear(id)}。

    + */ + 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} 顶掉平台签名实现)。 + * + *

    声明在本脚手架的嵌套类里:所有答卷域 IT 都继承它,因此共享同一份上下文配置 + * (Spring 的嵌套 {@code @TestConfiguration} 会沿测试类继承链发现)。

    + */ + @TestConfiguration + static class TestGatewayConfiguration { + + @Bean + @Primary + InMemoryModuleTreeSource inMemoryModuleTreeSource() { + return new InMemoryModuleTreeSource(); + } + + @Bean + @Primary + InMemorySubjectDirectory inMemorySubjectDirectory() { + return new InMemorySubjectDirectory(); + } + } } diff --git a/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java b/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java new file mode 100644 index 0000000..7810eac --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java @@ -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; + +/** + * 数据范围解析单测(自验项 V28 全部 + V42 的"身份默认矩阵/customId 升级/回落"三半边)。 + * + *

    矩阵(契约 §8)在这里逐格断言;"受限身份的统计/导出 ⊆ 列表可见集"那半边是接口级事实, + * 在 {@code AnswerReadIT} 打真库断言。桩只顶掉模块树来源(UserBase 事实源), + * 解析规则本身全部走生产代码。

    + */ +@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 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())); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java index fe09da7..7ee1234 100644 --- a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java +++ b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java @@ -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 P21 裁决的落地断言,也是 V17/V14/V29 的权限层依据)。 + * 管理权闸门单测(执行手册 §5 P21 裁决的 T4 到期替换:授权模块集口径,roleId 硬编码整体移除)。 * - *

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

    + *

    口径:能进哪个管理面=被授权哪个模块行(手册 §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"超管不受单校开通约束")。

    + * + *

    桩={@link ModuleTreeSource} 的 lambda——本类不启 Spring、不连库,验证的是"给定授权集合, + * 判定对不对"。真实树来自 UserBase 的链路在 T9 联调验。

    */ -@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()); + List 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 rows = new ArrayList<>(); + for (String path : paths) { + BoaModule module = new BoaModule(); + module.setPath(path); + module.setChildren(new ArrayList()); + rows.add(module); + } + return rows; + }); } private BoaUser schoolUser(int roleId) { diff --git a/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java b/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java index 436059c..2fe9e6d 100644 --- a/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java +++ b/src/test/java/com/yunhai/formcenter/service/FormFileServiceIT.java @@ -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 内本地键"这条真实分支——测试顺手把这条读取形态钉住了。

    */ @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("附件文件名与上传记录不一致"); } // ==================== 小工具 ==================== diff --git a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java b/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java new file mode 100644 index 0000000..c20dae2 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java @@ -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 联调验)。 + * + *

    建模的是 UserBase 侧的两种模块行(手册 §2.2):{@code path} 非空=页面路由行、 + * {@code path} 空 + {@code customId}=纯权限锚点。缺省不给任何模块——与生产侧 + * fail-closed 同向(无授权=无管理权、范围回落仅本人提交),需要权限的用例显式授权。

    + */ +public class InMemoryModuleTreeSource implements ModuleTreeSource { + + private final Map> byUser = new ConcurrentHashMap<>(); + + /** 授权页面路由模块行(path 逐字用手册 §2.2 的那几条) */ + public void grantPaths(Integer userId, String... paths) { + List 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 pathsOf(Integer userId) { + List paths = new ArrayList<>(); + for (BoaModule module : byUser.getOrDefault(userId, Collections.emptyList())) { + if (module.getPath() != null) { + paths.add(module.getPath()); + } + } + return paths; + } + + @Override + public List 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.emptyList()))); + } + + private List 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()); + 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 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); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/InMemorySubjectDirectory.java b/src/test/java/com/yunhai/formcenter/service/InMemorySubjectDirectory.java new file mode 100644 index 0000000..6843773 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/InMemorySubjectDirectory.java @@ -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,代填存在性/同校判定注入这里取证)。 + * + *

    缺省口径=「UserBase 正常返回该用户且与查询者同校」,因为绝大多数用例关心的是别的规则; + * 需要反例时用 {@link #markMissing} / {@link #markSchool} 显式登记, + * 这样 P33 的两条拒绝分支(查不到、跨校)都有独立断言。

    + */ +public class InMemorySubjectDirectory implements SubjectDirectory { + + private final Set missing = Collections.newSetFromMap(new ConcurrentHashMap()); + private final Map 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); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java b/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java new file mode 100644 index 0000000..63ca943 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/OapiFormServiceIT.java @@ -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)。 + * + *

    断言的四件事:

    + *
      + *
    • 自包含:字典引用在出参里已内联展开成 {@code options:[{label,value}]}, + * 消费方不需要再请求字典(契约 §2.1);
    • + *
    • 机读值形状:每个字段带 {@code valueSchema}({@code FormValueSchemaBuilderTest} 已逐类断言形状, + * 这里断言它真的出现在下发链路里);
    • + *
    • 遮蔽规则与填报入口同源:{@code /oapi/form/schema} 走的就是 {@code resolvePublished}, + * 本校表单建好后同一请求指向本校行(契约 §1);
    • + *
    • 答卷给的是钉住版本:升版后读历史答卷,schema 仍是它提交时那张表(契约 §9"快照"语义的前提)。
    • + *
    + * + *

    信封 {@code code=0} 与验签墙是平台链({@code /oapi/*} 401 由 {@code local-debug/t4-read-smoke.sh} + * 启动冒烟取证),本类断言的是 data 载荷内容。

    + */ +@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 下发:给的是钉住版本的 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); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/PlatformSubjectDirectoryTest.java b/src/test/java/com/yunhai/formcenter/service/PlatformSubjectDirectoryTest.java new file mode 100644 index 0000000..5a7bd7a --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/PlatformSubjectDirectoryTest.java @@ -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 调用)。 + * + *

    判据取 UserBase {@code /oapi/user/byId} 下发的三层字段:{@code schools}(全部可选身份的学校, + * 本期起填充)为主,{@code school}(当前激活学校)与 {@code currentRole.schId} 为兜底—— + * 三层任一含会话校即算同校。之所以不只看 {@code school}:多校身份的人当前激活校是"切到哪所"的运行时状态, + * 用它判同校会把合法的第二身份代填误拒。

    + */ +@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.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; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java new file mode 100644 index 0000000..debfff3 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java @@ -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)共用同一份事实, + * 从而能逐身份比对"统计/导出 ⊆ 列表可见集"(V27/V42 的硬断言)。 + * + *

    数据集刻意包含的形状:

    + *
      + *
    • {@code advisor} 字段在 v1 schema 里存在但不声明 searchable,v2 才声明—— + * V9(升版声明 searchable 后旧答卷可按新字段筛,靠发布时补建索引行)用的就是它;
    • + *
    • {@code cert} 是 upload 且被勾了 searchable——P28(无检索算子:不写索引行、不进筛选候选);
    • + *
    • 学生自填的行 {@code subject_id} 为空、管理员代填的行 {@code subject_id}=学生—— + * 契约 §8"学生=关于我"与"仅本人提交"两格的差异只有这种数据才测得出来;
    • + *
    • 一条被软删的行——四条读出路径都必须看不见(V30 的读侧半边)。
    • + *
    + */ +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); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/XlsxReader.java b/src/test/java/com/yunhai/formcenter/service/XlsxReader.java new file mode 100644 index 0000000..a65ee42 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/XlsxReader.java @@ -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 的取证要按"文件里到底写了什么"断言,不能只看服务返回值)。 + * + *

    用 easyexcel 自己的同步读:无模型类时返回 {@code List>}; + * {@code headRowNumber(0)} 表示表头也当数据行读回来(列顺序与表头文案的断言用)。

    + */ +final class XlsxReader { + + private XlsxReader() { + } + + /** 全部行(第 0 行是表头) */ + @SuppressWarnings("unchecked") + static List> allRows(byte[] xlsx) { + List raw = EasyExcel.read(new ByteArrayInputStream(xlsx)).sheet(0).headRowNumber(0).doReadSync(); + List> rows = new ArrayList<>(); + for (Object row : raw) { + rows.add((Map) row); + } + return rows; + } + + /** 数据行(去掉表头行) */ + static List> dataRows(byte[] xlsx) { + List> all = allRows(xlsx); + return all.isEmpty() ? all : new ArrayList<>(all.subList(1, all.size())); + } + + /** 第一列(答卷ID uuid)集合——"导出 ⊆ 列表可见集"的断言载体 */ + static List firstColumn(byte[] xlsx) { + List values = new ArrayList<>(); + for (Map row : dataRows(xlsx)) { + values.add(row.get(0)); + } + return values; + } + + /** 表头文案(按列序) */ + static List headers(byte[] xlsx) { + List> all = allRows(xlsx); + List titles = new ArrayList<>(); + if (all.isEmpty()) { + return titles; + } + Map header = all.get(0); + for (int column = 0; column < header.size(); column++) { + titles.add(header.get(column)); + } + return titles; + } +}