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