From a02f47ad5ce1ff3b291a43bf99ffb24311e296c3 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 16 Oct 2026 12:43:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(p95-fill):=20=E5=A1=AB=E6=8A=A5=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=94=B6=E5=9B=9E=E4=B8=AD=E5=BF=83=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=89=E5=B1=82=E8=A7=92=E8=89=B2=E6=A8=A1=E5=9E=8B=20+=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=AC=AC=200=20=E5=85=B3=20+=20=E6=88=91?= =?UTF-8?q?=E5=8F=AF=E5=A1=AB=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务书 P95 W1/W2/W3/W7。发一张表单原本要 UserBase 超管加一条 /f/{code} 模块行并组授权, 实测成本不可接受(10 张表=10 次跨系统操作),人群判定收回表单中心自解析。 - FillAccessService:audience × 会话身份(roleIds ∪ currentRole)× 管理员两表 → 层集合 ADMIN/MANAGE/FILL/空=NONE,谓词 canFill/canProxy/isAdmin + topTier;与 DataScopeService 读矩阵、FormAdminGuard 管理端入口三条线正交,结论互不供给 - AnswerService.doSubmit:钉版解析之后插第 0 关(先鉴权后业务,幂等重落在闸门后); requireSubject 吸收行为表——教师×面向学生只能代填本校学生(P33 existsInSchool 保留), 层③代填仍沿 canManage 现路径,层①普通成员代填照旧拒 - GET /api/form/my/fillable:会话校 ∪ 平台级取域、复用 FormDefService.pickByShadowing 做 (app_code,code) 遮蔽去重、仅已发布未软删、NONE 不入集,行带 canFill + myTier 两件事, deadline 升序 null 在后;新增 MyModels.FillableRow - h2 登记脚本快应用段 5→4 行:删 /f/ 填报行与学生组授权,保留应用行/管理页面行/ path 空导入导出锚点/学校开通;H2 冻结延续,本轮未在任何环境执行 - 测试:FillAccessServiceTest 16(V48)+ AnswerFillGateIT 14(V49)+ FillableListIT 10(V50), mvn -B -o clean test 全量 259 绿(教师×面向学生的造数据通道按新口径改代填, 读矩阵与限次等断言判据一字未改) DataScopeService 与 FormAdminGuard 两个文件本轮未被改动(红线 1/2)。 --- sql/h2_userbase_app_and_modules.sql | 55 ++-- .../api/FormMyAnswerController.java | 20 ++ .../com/yunhai/formcenter/model/MyModels.java | 32 ++ .../formcenter/repository/FormRepository.java | 10 + .../formcenter/service/AnswerService.java | 96 +++++- .../formcenter/service/FillAccessService.java | 173 +++++++++++ .../service/FillableFormService.java | 135 ++++++++ .../formcenter/service/FormDefService.java | 7 +- .../formcenter/service/AdminScopeIT.java | 20 +- .../service/AnswerConcurrencyIT.java | 4 +- .../formcenter/service/AnswerExportIT.java | 14 +- .../formcenter/service/AnswerFillGateIT.java | 289 ++++++++++++++++++ .../formcenter/service/AnswerReadIT.java | 22 +- .../formcenter/service/AnswerSubmitIT.java | 10 +- .../service/FillAccessServiceTest.java | 263 ++++++++++++++++ .../formcenter/service/FillableListIT.java | 262 ++++++++++++++++ .../yunhai/formcenter/service/MyAnswerIT.java | 6 +- .../formcenter/service/ReadFixture.java | 18 +- 18 files changed, 1367 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/yunhai/formcenter/service/FillAccessService.java create mode 100644 src/main/java/com/yunhai/formcenter/service/FillableFormService.java create mode 100644 src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java create mode 100644 src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java create mode 100644 src/test/java/com/yunhai/formcenter/service/FillableListIT.java diff --git a/sql/h2_userbase_app_and_modules.sql b/sql/h2_userbase_app_and_modules.sql index e593b1a..ffe0582 100644 --- a/sql/h2_userbase_app_and_modules.sql +++ b/sql/h2_userbase_app_and_modules.sql @@ -1,8 +1,16 @@ -- ============================================================================ -- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本 -- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役) +-- 改版:**P95(2026-10-16)快应用段 5→4 行——`/f/{code}` 填报行及其组授权整条撤销** +-- 终态口径(任务书 P95 §0.1 三层角色模型,用户 2026-10-16 裁决):填报人群由表单中心按 +-- `audience × 会话身份 × 管理员两表` 自解析(后端 FillAccessService + GET /api/form/my/fillable), +-- UserBase 侧**不再逐表单配填报模块行**——发一张表单求一次超管的成本就此归零。 +-- 快应用树只剩四种行:数据查看 / 统计 两个页面行 + 导入 / 导出 两个 path 空按钮锚点。 +-- **中心应用五行(200~204)与 /admin/* 四行原样不动**(P95 红线 2:管理端入口仍只认模块行)。 +-- ⚠ 存量环境里已录入的 `/f/` 行不在本脚本删除范围内(脚本只做"不存在才插",从不删行)—— +-- 清理清单见 docs/P95-report.md 的「存量清理」段,由超管在管理端手点删。 -- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、 --- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.5(中心=BUSINESS 应用, +-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8;P95 注记后收敛为两步)、§6.5(中心=BUSINESS 应用, -- 模块行与快应用模块行两棵树不混) -- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役) -- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法) @@ -64,7 +72,7 @@ WHERE @center_app_id > 0 AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id); -- ---------------------------------------------------------------------------- --- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8 四步的前两步) +-- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8;**P95 注记后 R8 只剩①建应用行 + ④学校开通**) -- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改 -- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。 -- ---------------------------------------------------------------------------- @@ -85,20 +93,20 @@ SET @quick_app_id = 0; -- ← 按上一步结果填;0=还没建 -- WHERE @quick_app_id = 0; -- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填 --- 模块行五条(方案 §6.2 表的典型配法)。path 形态: --- 填报 = /f/{code}(该快应用名下的表单 code,经 resolve 换 uuid——快应用不授权定义管理行,§3.4) +-- 模块行四条(P95 终态;方案 §6.2 表在 P95 注记后只剩这四行是 UserBase 侧的事)。path 形态: -- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段) -- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树") +-- ⚠ 原「填报 /f/{code}」行**自 P95 起不再登记**:填报入口由中心按三层模型解析 +-- (GET /api/form/my/fillable),模块行不参与;custom_id 300 段就此空置,不要再复用成别的行。 INSERT INTO app_module (app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) SELECT * FROM ( - SELECT @quick_app_id AS app_id, NULL AS parent_id, '填报' AS name, '300' AS custom_id, - CONCAT('/f/', @quick_code, '_form') AS path, 'edit' AS icon, 1 AS level, 300 AS sort_no, + SELECT @quick_app_id AS app_id, NULL AS parent_id, '数据查看' AS name, '301' AS custom_id, + CONCAT('/app/', @quick_code, '/data') AS path, 'category' AS icon, 1 AS level, 301 AS sort_no, @operator AS create_user, NOW() AS create_time - UNION ALL SELECT @quick_app_id, NULL, '数据查看', '301', CONCAT('/app/', @quick_code, '/data'), 'category', 1, 301, @operator, NOW() - UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW() - UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW() - UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW() + UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW() + UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW() + UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW() ) t WHERE @quick_app_id > 0 AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id); @@ -111,8 +119,9 @@ WHERE @quick_app_id > 0 -- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查) SET @grp_admin = 0; -- ← 中心管理员组 SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它) -SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 导出锚点) -SET @grp_student = 0; -- ← 快应用学生组(只给填报行) +SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 统计 + 导出锚点) +SET @grp_student = 0; -- ← 快应用学生组:**P95 起只需 3.2 的应用开通(门户可见性),不再勾任何模块行** + -- 填报入口由中心按三层模型给(面向学生的表单自动出现在学生账号的「可填写」里) -- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id) SET @school_id = 0; -- ← 目标学校 id @@ -141,20 +150,16 @@ SELECT @grp_all, m.id, 0, @operator, NOW() WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my' AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id); --- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行 +-- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(导入 303 是 1.5 预留位不授)。 +-- P95 起**学生组这一段整条撤销**:填报入口不再由模块行给(原"学生组=只给 /f/{code} 行"), +-- 学生所在组只需要 3.2 的**应用开通**(门户可见性与闸门照旧,V16 语义不变)—— +-- 能不能填哪张表由中心按三层模型判(后端 FillAccessService)。 INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) SELECT @grp_teacher, m.id, 0, @operator, NOW() FROM app_module m WHERE m.app_id = @quick_app_id AND @grp_teacher > 0 AND COALESCE(m.custom_id,'') <> '303' -- 303=导入(1.5 预留位不授) AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id); - -INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) -SELECT @grp_student, m.id, 0, @operator, NOW() - FROM app_module m - WHERE m.app_id = @quick_app_id AND @grp_student > 0 AND m.level = 1 AND m.path LIKE '/f/%' - AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_student AND p.module_id = m.id); - -- 3.5 ⚠ 过渡口径(T9 报告 §5.4 末段 / 问题单 P78 附注,需裁决后才可能取消这一步): -- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己), -- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。 @@ -174,13 +179,19 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW() -- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通; -- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。 -- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76) --- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报 +-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用的「填报」档看到该应用下**面向学生**的表单 +-- (P95:这张清单来自 GET /api/form/my/fillable,与快应用树里有没有 `/f/` 行无关—— +-- 删掉存量 `/f/` 行后门户导航、「可填写」列表、分享直链三路都不受影响,见 P95 报告收尾清单) -- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理), -- 角色设置里加的人落 school_admin(数据范围升级,不是菜单行)——T10 V43/V46 +-- ⑦P95 新判据(V52):快应用树里 `/f/` 行数=0(下面倒数第二条 chk),且中心段仍是 200~204 五行 -- ---------------------------------------------------------------------------- SELECT '中心模块行数(期望 5)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id UNION ALL -SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id +SELECT '快应用模块行数(P95 期望 4)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id +UNION ALL +SELECT '快应用 /f/ 填报行数(P95 期望 0)', COUNT(*) FROM app_module + WHERE app_id = @quick_app_id AND path LIKE '/f/%' UNION ALL SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module WHERE app_id = @center_app_id AND (path IS NULL OR path = '') diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java index dc3bc0a..c52f65c 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java @@ -3,6 +3,7 @@ package com.yunhai.formcenter.controller.api; import com.yunhai.formcenter.model.MyModels; import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.service.AnswerReadService; +import com.yunhai.formcenter.service.FillableFormService; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; @@ -38,6 +39,7 @@ import java.util.List; public class FormMyAnswerController { private final AnswerReadService readService; + private final FillableFormService fillableService; /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */ @GetMapping("/list") @@ -96,6 +98,24 @@ public class FormMyAnswerController { return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary()); } + /** + * 「可填写」列表(P95 W3,V50):填报入口清单,不是答卷清单—— + * 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行按 P95 三层模型过入口层,NONE 不入集。 + * + *

这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉: + * 谁能填什么从此由中心按 {@code audience × 身份} 自解析,UserBase 侧不再需要逐表单配模块行 + * (任务书 P95 §0)。它不看模块行、也不看数据范围矩阵:入口层与那两条线正交 + * ({@code FillAccessService} 类注释记了三条线的分工)。

+ * + * @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单 + */ + @GetMapping("/fillable") + public List fillable(BoaUser user, + @RequestParam(value = "appCode", required = false) String appCode) { + requireSession(user); + return fillableService.listFillable(user, appCode); + } + private static void requireSession(BoaUser user) { if (user == null || user.getId() == null) { throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); diff --git a/src/main/java/com/yunhai/formcenter/model/MyModels.java b/src/main/java/com/yunhai/formcenter/model/MyModels.java index 6899612..1d880f1 100644 --- a/src/main/java/com/yunhai/formcenter/model/MyModels.java +++ b/src/main/java/com/yunhai/formcenter/model/MyModels.java @@ -45,4 +45,36 @@ public final class MyModels { /** yyyy-MM-dd HH:mm:ss */ private String submitTime; } + + /** + * 一行"我可填的表单"(P95 W3,{@code GET /api/form/my/fillable}):填报入口清单的行形状。 + * + *

它不是答卷行:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页、 + * 进完之后按 P95 三层模型他算哪一层"。所以字段是表单身份 + 两个入口层结论, + * 与 {@link ItemView} 没有共同祖先。

+ * + *

{@code canFill} 与 {@code myTier} 是两件事,前端两处消费: + * {@code canFill=false} 且 {@code myTier=MANAGE} 的行(教师×面向学生)在「可填写」列表里不出现, + * 但要留在"数据管理"tab 的候选里——这正是 P95 把入口层结论下发而不是只下发一个布尔的原因。 + * 数字口径与定义域一致:{@code audience}/{@code fillLimit} 下发列值(1/2、0/1), + * {@code myTier} 下发枚举名(ADMIN/MANAGE/FILL,NONE 的行根本不入集)。

+ */ + @Getter + @Setter + public static class FillableRow { + private String code; + private String name; + private String appCode; + /** 面向人群:1 学生 / 2 教师(契约 §10 列值) */ + private Integer audience; + /** yyyy-MM-dd HH:mm:ss;null=不限截止 */ + private String deadline; + /** 限填次数:0 不限 / 1 限一次 */ + private Integer fillLimit; + private String groupTag; + /** 能否以本人身份填报(层①∪层③) */ + private Boolean canFill; + /** 并集里的最高层:ADMIN > MANAGE > FILL(NONE 不入集,故不会为 null) */ + private String myTier; + } } diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java index b2e4a63..7175b98 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java @@ -43,4 +43,14 @@ public interface FormRepository extends JpaRepository { List
findResolveCandidates(@Param("code") String code, @Param("appCode") String appCode, @Param("schIds") Collection schIds); + + /** + * 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里已发布且未软删的表单头表。 + * + *

{@code status} 写死在 SQL 而 app_code 过滤留在应用层,同 {@link #findLiveBySch} 的口径: + * 生命周期是这一把查询的语义(草稿/停用压根不是候选),而 app_code 是可选筛项, + * 可空参数在 MySQL 上的类型推断有坑(P10)。遮蔽去重与入口层过滤都在服务侧。

+ */ + @Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 and f.status = 1") + List findPublishedInDomain(@Param("schIds") Collection schIds); } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index 92cbbb9..0b3d0e0 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -25,19 +25,25 @@ import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Set; import java.util.UUID; /** * 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关: * *
- * 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本)
- *          → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid)
- *          → 写 answer_index(只写 searchable)
+ * 钉版解析 → 第 0 关·填报入口层(P95) → 截止 → 幂等命中 → subject 授权+存在性同校(P33)
+ *          → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
+ *          → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
  * 
* *

为什么顺序是这样:

*
    + *
  • 先鉴权后业务(P95):入口层放在钉版之后(要 {@code form.audience} 才能判人群)、 + * 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。 + * 幂等重落在闸门之后,所以被闸门拒的人重放同样是 403——这是任务书 W2 明示可接受的取舍; + * 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中 + * {@code belongsToCaller} 即返回原答卷);
  • *
  • 幂等检查必须在限次之前——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";
  • *
  • 限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插 * (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);
  • @@ -46,6 +52,12 @@ import java.util.UUID; * "软删条目仍放行"的口径也就无法表达(契约 §2.1)。 *
* + *

第 0 关与后面两关的分工:{@link FillAccessService} 判"这个身份在这张表单上算哪一层" + * (入口层,P95 起取代 {@code /f/{code}} 模块行授权);{@link #requireSubject} 判"subject 能不能指向 + * 别人"(ADMIN 沿 {@link FormAdminGuard#canManage} 现路径、MANAGE 走 P33 存在性); + * {@link DataScopeService} 判"写进去之后谁能看见这行"(读矩阵,与本类无关)。 + * 三条线各自独立,改一条不动另一条。

+ * *

事务形态:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。 * 目的是让"幂等键撞 uk"这种并发结局能在事务外重新读取原答卷并按契约返回 200+原 answer_id * (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。

@@ -62,6 +74,7 @@ public class AnswerService { private final FormDefService defService; private final FormDictService dictService; private final FormAdminGuard adminGuard; + private final FillAccessService fillAccess; private final SubjectDirectory subjectDirectory; private final AnswerRepository answerRepository; private final FillQuotaRepository quotaRepository; @@ -99,12 +112,16 @@ public class AnswerService { Form form = published.form(); String schemaJson = published.versionRow().getSchemaJson(); - // 2) 截止(datetime 粒度,过期接口拒绝,契约 §5) + // 2) 第 0 关(P95 三层模型):填报入口层——钉版之后才有 audience 可判,截止之前先鉴权 + Set tiers = fillAccess.resolve(user, form); + requireFillEntry(user, tiers, form, req.getSubjectId(), submitterId); + + // 3) 截止(datetime 粒度,过期接口拒绝,契约 §5) if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) { throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode()); } - // 3) 幂等命中→200+原 answer_id(在限次之前,见类注释) + // 4) 幂等命中→200+原 answer_id(在限次之前,见类注释) Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null); if (existing != null) { if (!belongsToCaller(existing, user)) { @@ -114,18 +131,18 @@ public class AnswerService { return toView(existing, true); } - // 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33) - Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId); + // 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33) + Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId); - // 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 + // 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属 String valuesJson = valuesToJson(req.getValues()); FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode)); assertAttachmentsOwned(schId, valuesJson, schemaJson); - // 6) 限次占位(uk 是并发兜底) + // 7) 限次占位(uk 是并发兜底) String quotaKey = occupyQuota(form, subjectId, submitterId); - // 7) 落答卷 + // 8) 落答卷 Answer answer = new Answer(); answer.setUuid(newUuid()); // P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行) @@ -147,7 +164,7 @@ public class AnswerService { // 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态 answer = answerRepository.saveAndFlush(answer); - // 8) 索引:只写 searchable 字段(V3/V9) + // 9) 索引:只写 searchable 字段(V3/V9) indexService.write(answer, schemaJson); log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", @@ -158,16 +175,45 @@ public class AnswerService { // ==================== 各环节 ==================== - private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId, Long schId) { + /** + * 第 0 关(P95 W2 行为表):入口层判"这个身份在这张表单上到底能不能写"。 + * + *

两条拒绝各有独立文案,因为它们是两种不同的用户动作失败了: + * NONE=压根不在面向人群里(家长/工作号/局领导、学生填教师问卷),直链进来也是 403; + * 仅 MANAGE 的自填=人在人群口径里、但档位只给代填不给自填(教师×学生面向), + * 文案要告诉他"改成代填学生就能提交",否则这条 403 无从下手。

+ */ + private void requireFillEntry(BoaUser user, Set tiers, Form form, + Integer subjectId, Integer submitterId) { + if (tiers.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向" + + audienceLabel(form) + ",当前身份 " + sessionIdentity(user) + " 不在可填报人群内" + + "(可填=面向人群本人;教师可代填本校学生;管理员不受限)"); + } + boolean proxy = subjectId != null && !subjectId.equals(submitterId); + if (!proxy && !fillAccess.canFill(tiers)) { + throw new BizException(ErrorCode.FORBIDDEN, "教师不能以本人身份填报学生面向表单,可代填本校学生" + + "(subjectId 传被代填学生 id):表单「" + form.getCode() + "」面向" + audienceLabel(form)); + } + } + + private Integer requireSubject(BoaUser user, Set tiers, Integer subjectId, + Integer submitterId, Long schId) { if (subjectId == null || subjectId.equals(submitterId)) { return subjectId; } - if (!adminGuard.canManage(user)) { - throw new BizException(ErrorCode.FORBIDDEN, - "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")"); + if (fillAccess.isAdmin(tiers)) { + // 层③的代填仍走 canManage 现路径(P95 红线 3"canManage 代填路径原样"): + // 入口层把 ADMIN 判出来不等于给一条比现状更宽的写通道——没被授权管理模块行的人照旧拒 + if (!adminGuard.canManage(user)) { + throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId)); + } + } else if (!fillAccess.canProxy(tiers)) { + // 层①普通成员(学生×学生面向、教师×教师面向)代填=拒,与 P95 之前的现行为逐字同文案 + throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId)); } // P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。 - // 本人自填不查(会话里的人本来就存在),只验代填那一个 id。 + // 本人自填不查(会话里的人本来就存在),只验代填那一个 id。层②与层③共用这一条(红线 3)。 if (!subjectDirectory.existsInSchool(subjectId, schId)) { throw new BizException(ErrorCode.PARAM_ERROR, "被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId); @@ -175,6 +221,24 @@ public class AnswerService { return subjectId; } + private static String forbiddenProxy(Integer submitterId, Integer subjectId) { + return "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")"; + } + + private static String audienceLabel(Form form) { + return FormEnums.Audience.TEACHER.getCode() == (form.getAudience() == null + ? FormEnums.Audience.STUDENT.getCode() : form.getAudience()) ? "教师" : "学生"; + } + + /** 拒绝文案里的身份要素(会话 roleIds + 当前身份,便于运维一眼看出是人群配错还是身份不对) */ + private static String sessionIdentity(BoaUser user) { + StringBuilder sb = new StringBuilder("id=").append(user.getId()).append(" roleIds=").append(user.getRoleIds()); + if (user.getCurrentRole() != null && user.getCurrentRole().getRoleId() != null) { + sb.append("(当前=").append(user.getCurrentRole().getRoleId()).append(")"); + } + return sb.toString(); + } + /** 返回占住的 limit_key(null=该表单不限次,未占位) */ private String occupyQuota(Form form, Integer subjectId, Integer submitterId) { if (!FormEnums.FillLimit.once(form.getFillLimit())) { diff --git a/src/main/java/com/yunhai/formcenter/service/FillAccessService.java b/src/main/java/com/yunhai/formcenter/service/FillAccessService.java new file mode 100644 index 0000000..f536a1e --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FillAccessService.java @@ -0,0 +1,173 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.enums.FormEnums; +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.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * 填报入口层解析(P95 任务书 §0.1 三层角色模型):把"会话用户 + 这张表单"解析成一个层集合, + * 回答"填报入口给不给这个人开、开到哪一档"。纯解析、无写路径、不碰任何一张表。 + * + *

三条线的分工(互不越界,P95 红线 1/2):

+ *
    + *
  • UserBase 模块行管"能不能进页面"——{@code /admin/*} 四行是管理面入口凭证 + * ({@link FormAdminGuard},P80 口径"入口只认模块行,两表只升范围"一字不动)。 + * {@code /f/{code}} 填报模块行自 P95 起不再承担填报授权;
  • + *
  • 本类管"填报入口给谁开"(P95 新增)——人群由表单的 {@code audience} × 会话身份 × + * 管理员两表在中心侧自解析,不需要超管逐个表单配模块行;
  • + *
  • {@link DataScopeService} 管"进页面后能看哪些行"(读矩阵,一字不动)。
  • + *
+ * 三者判定因子有重叠(都看 roleIds、都看两表),但结论互不供给:本类不给数据面放行, + * 数据面也不给填报入口放行。 + * + *

层与谓词(任务书 §0.1 表原样):

+ *
+ *   ADMIN  超管(type==1) ∪ school_admin 命中会话校 ∪ form_admin 命中该表单 ∪ roleIds 含 2/7
+ *   MANAGE audience=学生 且 roleIds 含 1(教师)——可代填本校学生,数据面仍是"仅本人提交"
+ *   FILL   (audience=学生 且 roleIds 含 3) ∪ (audience=教师 且 roleIds 含 1)
+ *   NONE   其余一切(家长4/局领导5/工作号6/未知角色、学生×教师面向、roleIds 全不命中)= 空集
+ * 
+ * + *

多身份=并集:{@code roleIds}(全部正常态角色)∪ 会话 {@code currentRole.roleId} + * 逐层判定后合并(沿 {@link DataScopeService} 的 {@code identityRoleIds} 口径,防 UserBase 两侧不一致漏格)。 + * 一个层集合可以同时含多层(如教师×学生面向 = {@code {MANAGE}},校领导 = {@code {ADMIN}})。

+ * + *

与任教班级 stub 的关系:层②的数据面语义就是现状(矩阵该格生效=仅本人提交), + * 本类只是把"教师可以往本校学生名下写数据"这件事从模块行授权收回到中心解析, + * {@code DataScopeService} 的注释与取值一概不动。

+ */ +@Service +@RequiredArgsConstructor +public class FillAccessService { + + /** 超管用户类型(同 {@link FormAdminGuard}/{@link DataScopeService}:不是 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 AdminScopeSource adminScopeSource; + + /** 填报入口层({@link #resolve} 的元素;未命中任何层=空集,语义即任务书里的 NONE) */ + public enum Tier { + /** 层③:全部管理 + 可填 + 可代填 */ + ADMIN, + /** 层②:教师×学生面向,可代填本校学生(数据面维持仅本人提交) */ + MANAGE, + /** 层①:本人身份可填 */ + FILL + } + + /** + * 解析该会话用户在这张表单上的层集合。 + * + * @param form 目标表单({@code audience} 决定用哪一列,{@code uuid} 决定 form_admin 命中); + * null 按面向学生列解析,且不产生 form_admin 命中(无表单可判), + * 与 {@link DataScopeService#resolve} 的 null 口径同形 + * @return 层集合;空集=NONE(没有任何入口)。永不为 null + */ + public Set resolve(BoaUser user, Form form) { + if (user == null || user.getId() == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); + } + Set tiers = EnumSet.noneOf(Tier.class); + List roleIds = identityRoleIds(user); + if (isSuperAdmin(user) || grantedByAdminTables(user, form) || holdsAdminRole(roleIds)) { + tiers.add(Tier.ADMIN); + } + boolean studentFacing = FormEnums.Audience.STUDENT.getCode() + == (form == null || form.getAudience() == null + ? FormEnums.Audience.STUDENT.getCode() : form.getAudience()); + if (studentFacing && roleIds.contains(ROLE_TEACHER)) { + tiers.add(Tier.MANAGE); + } + if (studentFacing ? roleIds.contains(ROLE_STUDENT) : roleIds.contains(ROLE_TEACHER)) { + tiers.add(Tier.FILL); + } + return tiers; + } + + /** 层①∪层③:能否以本人身份填报(层②只有代填,自填不在这里——由调用侧按行为表拒绝并给文案) */ + public boolean canFill(Set tiers) { + return tiers != null && (tiers.contains(Tier.FILL) || tiers.contains(Tier.ADMIN)); + } + + /** 层②∪层③:能否代填他人(真正能不能落到某个人身上还要过 P33 的 existsInSchool) */ + public boolean canProxy(Set tiers) { + return tiers != null && (tiers.contains(Tier.MANAGE) || tiers.contains(Tier.ADMIN)); + } + + /** 层③:管理员层命中(超管 / 两表 / 校领导(2) / 管理员(7)) */ + public boolean isAdmin(Set tiers) { + return tiers != null && tiers.contains(Tier.ADMIN); + } + + /** 并集里的最高层(序 ADMIN > MANAGE > FILL;空集返回 null=NONE),供列表的 {@code myTier} 用 */ + public Tier topTier(Set tiers) { + if (tiers == null || tiers.isEmpty()) { + return null; + } + for (Tier candidate : Tier.values()) { + if (tiers.contains(candidate)) { + return candidate; + } + } + return null; + } + + /** + * 管理员两表是否把这个人抬进层③:{@code school_admin} 命中会话校、 + * 或 {@code form_admin} 命中这张表单(口径与 {@link DataScopeService} 读侧同源, + * 但结论只用在入口层,不改数据范围)。 + * + *

与读侧的差别只有一处,且是有意的:两表在这里不看表单归属域。 + * 读侧要区分"本校表单→全部 / 基础表单→本校提交",而入口层问的是"这个人是不是管理员", + * 基础表单的管理员同样是管理员;校域不变式({@code answer.sch_id}=会话校)也不因代填而变。

+ */ + private boolean grantedByAdminTables(BoaUser user, Form form) { + Long ownSch = sessionSchId(user); + if (ownSch != null && adminScopeSource.isSchoolAdmin(ownSch, user.getId())) { + return true; + } + return form != null && adminScopeSource.isFormAdmin(form.getUuid(), user.getId()); + } + + /** 校领导(2)/管理员(7):平台标准角色里的管理身份(契约 §8 矩阵那两行同一个因子) */ + private static boolean holdsAdminRole(List roleIds) { + return roleIds.contains(ROLE_ADMIN) || roleIds.contains(ROLE_SCHOOL_LEADER); + } + + /** 参与解析的身份:{@code roleIds}(全部正常态角色)并上会话 {@code currentRole.roleId}(同 DataScopeService) */ + private static 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 static boolean isSuperAdmin(BoaUser user) { + return user.getType() != null && user.getType() == SUPER_ADMIN_TYPE; + } + + private static Long sessionSchId(BoaUser user) { + Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); + return schId == null ? null : Long.valueOf(schId); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java new file mode 100644 index 0000000..680c15f --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java @@ -0,0 +1,135 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.model.MyModels; +import com.yunhai.formcenter.repository.FormRepository; +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.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 「我可填」清单解析(P95 W3,{@code GET /api/form/my/fillable}):把"这个会话用户能进哪些表单的填报页" + * 算成一张列表——P91 那个列表的数据源,从此不再吃 UserBase 模块树。 + * + *

四步,一步不多:

+ *
    + *
  1. 取域:会话校 ∪ 平台级({@code sch_id=0}),只认 {@code status=1} 已发布且未软删的头表;
  2. + *
  3. 遮蔽去重:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落—— + * 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍: + * 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);
  4. + *
  5. 逐行过入口层:{@link FillAccessService#resolve} 为空集(NONE)的行不入集;非空即入集, + * {@code canFill} 与 {@code myTier} 一起下发——层②(教师×面向学生)的行 {@code canFill=false}, + * 前端「可填写」列表把它筛掉,但"数据管理"tab 的候选要靠它,所以不能只下发一个布尔;
  6. + *
  7. 排序:deadline 升序、null(不限)在后、同档按 code。
  8. + *
+ * + *

不分页:一所学校 + 平台级里可填的表单是个位数到几十张(P95 W3 的量级假设,报告已记), + * 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。

+ * + *

与数据面的关系:本类只回答"入口给谁开",不看 {@link DataScopeService}。 + * 列表里出现某张表单,不代表进去能看到别人的行(层②教师看到的仍是仅本人提交)。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FillableFormService { + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final FormRepository formRepository; + private final FillAccessService fillAccess; + + /** + * 我可填清单。 + * + * @param appCode 可选;空/缺省=全部应用(中心 + 所有快应用),非空=只要该 app_code 下的表单 + * @return 已按 deadline/code 排好序的行集;无权限的人拿到的是空集而不是异常 + */ + public List listFillable(BoaUser user, String appCode) { + Long schId = requireSessionSchId(user); + String cleanApp = appCode == null ? "" : appCode.trim(); + List domain = Form.PLATFORM_SCH_ID == schId + ? Collections.singletonList(Form.PLATFORM_SCH_ID) + : Arrays.asList(schId, Form.PLATFORM_SCH_ID); + + // 先按 (app_code, code) 分组,再逐组走遮蔽规则——resolve 是"一个 code 一次判定", + // 列表要把同一条判定摊到多个 code 上,分组就是这条摊法(不是另写一份遮蔽逻辑) + Map> groups = new LinkedHashMap<>(); + for (Form form : formRepository.findPublishedInDomain(domain)) { + if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) { + continue; + } + String key = form.getAppCode() + "#" + form.getCode(); + List bucket = groups.get(key); + if (bucket == null) { + bucket = new ArrayList<>(); + groups.put(key, bucket); + } + bucket.add(form); + } + + List visible = new ArrayList<>(); + for (List bucket : groups.values()) { + Form winner = FormDefService.pickByShadowing(bucket, schId); + if (winner == null) { + continue; + } + if (winner.getCurrentVersion() == null) { + // status=1 却没有版本指针=定义坏状态,resolve 会以"当前发布版本行缺失"拒绝, + // 这里同样不给入口(宁缺勿滥:列表里出现点了报 500 的表单比少一张更糟) + log.warn("[我可填] 表单 {}/{} 已发布但当前版本指针为空,跳过", winner.getAppCode(), winner.getCode()); + continue; + } + visible.add(winner); + } + + Collections.sort(visible, Comparator + .comparing((Form form) -> form.getDeadline() == null ? LocalDateTime.MAX : form.getDeadline()) + .thenComparing(form -> form.getCode() == null ? "" : form.getCode())); + + List rows = new ArrayList<>(); + for (Form form : visible) { + Set tiers = fillAccess.resolve(user, form); + if (tiers.isEmpty()) { + continue; + } + MyModels.FillableRow row = new MyModels.FillableRow(); + row.setCode(form.getCode()); + row.setName(form.getName()); + row.setAppCode(form.getAppCode()); + row.setAudience(form.getAudience()); + row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS)); + row.setFillLimit(form.getFillLimit()); + row.setGroupTag(form.getGroupTag()); + row.setCanFill(fillAccess.canFill(tiers)); + row.setMyTier(fillAccess.topTier(tiers).name()); + rows.add(row); + } + return rows; + } + + /** 会话校(同 {@code AnswerService} 的口径:未登录与无学校归属两段分开拒,文案与 my 面其余端点一致) */ + private static Long requireSessionSchId(BoaUser user) { + if (user == null || user.getId() == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); + } + if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { + throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属"); + } + return Long.valueOf(user.getCurrentRole().getSchId()); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 909afce..8fe92a9 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -656,8 +656,11 @@ public class FormDefService { /** 遮蔽规则(U5):同 (app_code, code) 本校与平台级都在时,本校胜出且不回落—— * 本校行哪怕处于草稿/停用,也不让请求穿透到平台级(否则"本校遮蔽"会变成可绕过的建议)。 - * 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。 */ - private Form pickByShadowing(List candidates, Long schId) { + * 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。 + * + *

P95 起 {@code FillableFormService} 也要这条规则(「我可填」列表按 (app_code, code) 分组后逐组判), + * 所以把它收成包内静态方法而不是复制一份——两条链路共用一条遮蔽实现(同 P20 的取舍)。

*/ + static Form pickByShadowing(List candidates, Long schId) { Form platformFallback = null; for (Form form : candidates) { if (form.getSchId() == null) { diff --git a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java index eb36733..4914499 100644 --- a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java @@ -58,6 +58,8 @@ class AdminScopeIT extends AnswerTestSupport { private static final String QUICK_CODE = "t10_quick_drill"; /** 快应用面(方案 §6.2):中心之外的 app_code,用来证"本校快应用表单也被校级管理员覆盖" */ private static final String QUICK_APP = "fc-drill"; + /** 教师代填的被代填人(P95 起教师造不出"自填行",只能代填;这个 id 不参与任何"关于我"断言) */ + private static final int PROXY_SUBJECT = 20264777; private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}]}]}"; @@ -202,17 +204,31 @@ class AdminScopeIT extends AnswerTestSupport { seed.quickUuid = publishQuickForm(QUICK_APP, QUICK_CODE); seed.baseByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), BASE_CODE, "本校学生甲"); - seed.baseByTeacher = submit(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙"); + seed.baseByTeacher = submitByTeacherProxy(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙"); seed.baseAtOtherSchool = submit(user(ReadFixture.STUDENT_2, OTHER_SCH, 3), BASE_CODE, "他校学生丙"); seed.baseLegacyNull = insertLegacyAnswer(seed.baseUuid, "存量没有学校列的行"); seed.ownByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), OWN_CODE, "自己学校的行"); - seed.ownByTeacher = submit(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行"); + seed.ownByTeacher = submitByTeacherProxy(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行"); seed.quickByStudent = submitQuick(user(ReadFixture.STUDENT_1, SCH, 3), "快应用的行"); return seed; } + /** + * P95 第 0 关之后,教师×面向学生表单只能代填本校学生(W2 行为表层②那一行), + * 以本人身份自填会被拒。本类要的是"教师提交过的行"这个事实(submitter=教师), + * 所以造它就走代填路径——读侧矩阵断言(仅本人提交=submitter 谓词)吃的还是同一个谓词。 + * + *

被代填人刻意用 {@code PROXY_SUBJECT} 这个新 id:它不出现在任何"关于我"断言里, + * 换 subject 不会把学生视角的可见集改大(V43"未命中=矩阵默认一格不变"依赖这点)。

+ */ + private String submitByTeacherProxy(BoaUser teacher, String code, String name) { + AnswerModels.SubmitRequest request = submit(code, "{\"name\":\"" + name + "\"}"); + request.setSubjectId(PROXY_SUBJECT); + return answerService.submit(teacher, request).getAnswerId(); + } + /** 造一条 T10 之前的存量行(submitter_sch_id=NULL):新链路的提交一定会落值,只有直接入库才出得来 */ private String insertLegacyAnswer(String formUuid, String name) { Answer answer = new Answer(); diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java index b3635fd..9b38b61 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerConcurrencyIT.java @@ -100,7 +100,7 @@ class AnswerConcurrencyIT extends AnswerTestSupport { Outcome[] outcomes = runTwo(() -> { try { - String answerId = answerService.submit(user(70001, SCH_C, 1), submit(code, VALUES)) + String answerId = answerService.submit(user(70001, SCH_C, 3), submit(code, VALUES)) .getAnswerId(); return new Outcome(answerId, null); } catch (BizException e) { @@ -130,7 +130,7 @@ class AnswerConcurrencyIT extends AnswerTestSupport { Outcome[] outcomes = runTwo(() -> { try { - return new Outcome(answerService.submit(user(70002, SCH_C, 1), + return new Outcome(answerService.submit(user(70002, SCH_C, 3), submitWithKey(code, key, VALUES)).getAnswerId(), null); } catch (BizException e) { return new Outcome(null, e.getMessage()); diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java index bb127fe..e315b6c 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerExportIT.java @@ -1,6 +1,7 @@ package com.yunhai.formcenter.service; import com.yunhai.formcenter.entity.FormFile; +import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.ReadModels; import com.yunhai.formcenter.repository.FormFileRepository; import com.yunhai.platform.apistd.BizException; @@ -153,7 +154,11 @@ class AnswerExportIT extends AnswerTestSupport { // ==================== 数据准备 ==================== /** - * 建表单并发布,管理员身份写 {@code total} 条答卷,其中前 3 条由教师1 提交(范围用例的"他可见集")。 + * 建表单并发布,管理员身份写 {@code total} 条答卷,其中前 3 条由教师1 代填提交 + * (范围用例的"他可见集"=submitter=教师1 的那三条)。 + * + *

P95 第 0 关之后,教师×面向学生表单以本人身份自填会被拒(行为表层②),所以这三条走代填路径: + * 谓词列还是 {@code submitter_id},"仅本人提交"的可见集与导出行数断言一字未改。

* * @return 表单 uuid */ @@ -176,7 +181,12 @@ class AnswerExportIT extends AnswerTestSupport { + "\"subjects\":[\"phy\",\"math\"]," + "\"cert\":[{\"id\":" + file.getId() + ",\"fileName\":\"" + file.getFileName() + "\",\"key\":\"" + file.getStoreKey() + "\"}]}"; - answerService.submit(i < 3 ? teacher : admin, submit(FORM, values)); + AnswerModels.SubmitRequest request = submit(FORM, values); + if (i < 3) { + // P95:教师在这一列人群上只有代填档,代填一个不参与任何"关于我"断言的 id + request.setSubjectId(ReadFixture.TEACHER_PROXY_1); + } + answerService.submit(i < 3 ? teacher : admin, request); } return formUuid; } diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java new file mode 100644 index 0000000..882123e --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AnswerFillGateIT.java @@ -0,0 +1,289 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * P95 第 0 关与教师代填(自验项 V49):按任务书 W2 行为表逐行打真库 + * ({@code @Transactional} 回滚,沿 {@code AnswerSubmitIT} 先例)。 + * + *

行为表三档 × 自填/代填 = 六个格子,加上"代填仍受 P33 管"和"闸门排在截止与幂等之前"两条顺序断言。 + * 桩只顶掉 {@link SubjectDirectory}(UserBase 的人)与 {@link ModuleTreeSource}(模块授权树), + * 管理员两表用真行({@link #grantSchoolAdmin}/{@link #grantFormAdmin})——入口层与读侧共用同一个事实源, + * 用桩顶掉就等于没验接线。

+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("AnswerService - P95 第 0 关与教师代填(V49)") +class AnswerFillGateIT extends AnswerTestSupport { + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}," + + "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true," + + "\"options\":[{\"label\":\"高二\",\"value\":\"g2\"},{\"label\":\"高三\",\"value\":\"g3\"}]}]}]}"; + + private static final String VALUES = "{\"name\":\"张三\",\"grade\":\"g2\"}"; + + private static final int STUDENT = 20265001; + private static final int TEACHER = 20265002; + private static final int CLASSMATE = 20265003; + + // ==================== 层① FILL:本人可填、代填仍拒 ==================== + + @Test + @DisplayName("V49 层①:学生×面向学生——自填放行;代填他人=拒(现行为文案逐字保留)") + void studentSelfFillPassesProxyRejected() { + String code = "p95_fill_self"; + publishForm(code, SCHEMA); + + assertThat(answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)).getAnswerId()).isNotBlank(); + + AnswerModels.SubmitRequest proxy = submit(code, VALUES); + proxy.setSubjectId(CLASSMATE); + assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), proxy)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权为他人填报:subjectId 只能等于本人"); + } + + @Test + @DisplayName("V49 层①:教师×面向教师=自填放行(audience 换列后同一个 roleId 落在 FILL)") + void teacherOnTeacherFacingFills() { + String code = "p95_fill_teacher"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest(); + teacherFacing.setAudience(2); + config(SCH, uuid, teacherFacing); + + assertThat(answerService.submit(user(TEACHER, SCH, 1), submit(code, VALUES)).getAnswerId()).isNotBlank(); + } + + // ==================== 层② MANAGE:只能代填 ==================== + + @Test + @DisplayName("V49 层②:教师×面向学生以本人身份自填被拒,文案明示'不能以本人身份填报…可代填本校学生'") + void teacherCannotSelfFillStudentFacing() { + String code = "p95_manage_self"; + publishForm(code, SCHEMA); + + assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), submit(code, VALUES))) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()) + .contains("教师不能以本人身份填报学生面向表单,可代填本校学生") + .contains(code); + }); + } + + @Test + @DisplayName("V49 层②:教师×面向学生代填本校学生=放行(P95 净新增能力),且落值满足校域不变式") + void teacherProxyForSchoolStudentPasses() { + String code = "p95_manage_proxy"; + String uuid = publishForm(code, SCHEMA); + subjects.markSchool(CLASSMATE, SCH); + + AnswerModels.SubmitRequest req = submit(code, VALUES); + req.setSubjectId(CLASSMATE); + String answerId = answerService.submit(user(TEACHER, SCH, 1), req).getAnswerId(); + + Answer stored = answerRepository.findByUuidAndSchId(answerId, SCH).orElse(null); + assertThat(stored).isNotNull(); + // 红线 4:answer.sch_id 恒=会话校,代填不改域(P33/P80 口径不回退) + assertThat(stored.getSchId()).isEqualTo(SCH); + assertThat(stored.getSubmitterId()).isEqualTo(TEACHER); + assertThat(stored.getSubjectId()).isEqualTo(CLASSMATE); + assertThat(stored.getSubmitterSchId()).isEqualTo(SCH); + assertThat(stored.getFormUuid()).isEqualTo(uuid); + } + + @Test + @DisplayName("V49 层②:代填的 subject 查无此人/跨校仍被 P33 拦(PARAM_ERROR)——新能力没绕过存在性校验") + void teacherProxyStillCheckedByP33() { + String code = "p95_manage_p33"; + publishForm(code, SCHEMA); + + Integer missing = 20265999; + subjects.markMissing(missing); + AnswerModels.SubmitRequest toMissing = submit(code, VALUES); + toMissing.setSubjectId(missing); + assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), toMissing)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR); + assertThat(e.getMessage()).contains("被代填人不存在或不属于本校"); + }); + + Integer foreign = 20265888; + subjects.markSchool(foreign, OTHER_SCH); + AnswerModels.SubmitRequest toForeign = submit(code, VALUES); + toForeign.setSubjectId(foreign); + assertThatThrownBy(() -> answerService.submit(user(TEACHER, SCH, 1), toForeign)) + .isInstanceOf(BizException.class).hasMessageContaining("被代填人不存在或不属于本校"); + } + + // ==================== 层③ ADMIN:可填 + 代填沿 canManage 现路径 ==================== + + @Test + @DisplayName("V49 层③:管理员(7)×面向学生自填与代填都放行(P95 之前就能填能代填,一格不减)") + void adminFillsAndProxies() { + String code = "p95_admin"; + publishForm(code, SCHEMA); + Integer adminId = 10001; + + assertThat(answerService.submit(user(adminId, SCH, 7), submit(code, VALUES)).getAnswerId()).isNotBlank(); + + AnswerModels.SubmitRequest proxy = submit(code, "{\"name\":\"李四\",\"grade\":\"g3\"}"); + proxy.setSubjectId(CLASSMATE); + assertThat(answerService.submit(user(adminId, SCH, 7), proxy).getAnswerId()).isNotBlank(); + } + + @Test + @DisplayName("V49 层③ 反例:进了 form_admin(入口层=ADMIN)但没被授权管理模块行——代填仍被 canManage 拒(红线 3'代填路径原样')") + void adminTierWithoutModuleRowKeepsCanManageGate() { + String code = "p95_admin_no_module_row"; + String uuid = publishForm(code, SCHEMA); + grantFormAdmin(uuid, STUDENT, SCH); + + // 入口层确实升到 ADMIN:自填放行(学生本来也能填,这里只是坐实"两表进了 ADMIN 档") + assertThat(answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES)).getAnswerId()).isNotBlank(); + + AnswerModels.SubmitRequest proxy = submit(code, VALUES); + proxy.setSubjectId(CLASSMATE); + assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), proxy)) + .isInstanceOf(BizException.class) + .hasMessageContaining("无权为他人填报:subjectId 只能等于本人"); + } + + @Test + @DisplayName("V49 层③:超管(type==1)无角色无学校归属以外的一切限制,面向学生表单直填放行") + void superAdminPassesEveryAudience() { + String code = "p95_super"; + String uuid = publishForm(code, SCHEMA); + BoaUser root = user(1, SCH, 9); + root.setType(1); + assertThat(answerService.submit(root, submit(code, VALUES)).getAnswerId()).isNotBlank(); + + FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest(); + teacherFacing.setAudience(2); + config(SCH, uuid, teacherFacing); + assertThat(answerService.submit(root, submit(code, VALUES)).getAnswerId()).isNotBlank(); + } + + // ==================== NONE:直链也 403 ==================== + + @Test + @DisplayName("V49 NONE:家长(4)/局领导(5)/工作号(6) 拿直链提交=FORBIDDEN,文案含表单 code 与会话身份要素") + void peripheralRolesAreRejected() { + String code = "p95_none"; + publishForm(code, SCHEMA); + + for (int roleId : new int[]{4, 5, 6}) { + int id = 20265100 + roleId; + assertThatThrownBy(() -> answerService.submit(user(id, SCH, roleId), submit(code, VALUES))) + .as("roleId=" + roleId) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()) + .contains("不在可填报人群内") + .contains(code) + .contains("id=" + id) + .contains("面向学生"); + }); + } + } + + @Test + @DisplayName("V49 NONE:学生×面向教师=FORBIDDEN(原'不授填报模块行进不来'的那格,现在由中心解析拒)") + void studentOnTeacherFacingIsRejected() { + String code = "p95_none_teacher_facing"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormConfigRequest teacherFacing = new FormModels.FormConfigRequest(); + teacherFacing.setAudience(2); + config(SCH, uuid, teacherFacing); + + assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不在可填报人群内") + .hasMessageContaining("面向教师"); + } + + // ==================== 顺序:闸门在截止与幂等之前 ==================== + + @Test + @DisplayName("V49 顺序:无资格的人对已过期表单拿到的是 403,不是'已过收集截止时间'(先鉴权后业务)") + void gateRunsBeforeDeadline() { + String code = "p95_gate_order"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormConfigRequest past = new FormModels.FormConfigRequest(); + past.setDeadline("2020-01-01"); + config(SCH, uuid, past); + + // 有资格的人仍按原语义拿到截止拒绝(既有环节不回退) + assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("已过收集截止时间"); + // 无资格的人连"这张表过期了"都问不出来 + assertThatThrownBy(() -> answerService.submit(user(20265201, SCH, 4), submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内"); + } + + @Test + @DisplayName("V49 顺序:被闸门拒的人重放同一幂等键仍是 403(闸门在幂等之前,任务书 W2 明示的取舍)") + void rejectedReplayIsStillForbidden() { + String code = "p95_gate_replay"; + publishForm(code, SCHEMA); + String key = newIdemKey(); + + assertThatThrownBy(() -> answerService.submit(user(20265202, SCH, 4), submitWithKey(code, key, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内"); + assertThatThrownBy(() -> answerService.submit(user(20265202, SCH, 4), submitWithKey(code, key, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("不在可填报人群内"); + assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", code)).isEmpty(); + } + + // ==================== 既有环节不回退(代表性用例) ==================== + + @Test + @DisplayName("V49 后半:闸门不吃既有环节——限一次、值校验、钉版顺序与语义原样") + void existingStagesAreUntouched() { + String code = "p95_stages"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormConfigRequest once = new FormModels.FormConfigRequest(); + once.setFillLimit(com.yunhai.formcenter.enums.FormEnums.FillLimit.ONCE.getCode()); + config(SCH, uuid, once); + BoaUser student = user(STUDENT, SCH, 3); + + assertThat(answerService.submit(student, submit(code, VALUES)).getAnswerId()).isNotBlank(); + assertThatThrownBy(() -> answerService.submit(user(STUDENT, SCH, 3), submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("限填一次"); + // 值校验仍按钉住版本(闸门放行之后才走到,顺序没被第 0 关挤掉) + assertThatThrownBy(() -> answerService.submit(user(CLASSMATE, SCH, 3), submit(code, "{\"grade\":\"g9\"}"))) + .isInstanceOf(BizException.class).hasMessageContaining("必填"); + } + + @Test + @DisplayName("V49 未登录/无学校归属两段的顺序不回退:先要得到会话,再谈入口层") + void sessionChecksStillFirst() { + String code = "p95_session"; + publishForm(code, SCHEMA); + + assertThatThrownBy(() -> answerService.submit(null, submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("未登录"); + + BoaUser noSchool = new BoaUser(); + noSchool.setId(20265301); + noSchool.setRoleIds(java.util.Collections.singletonList(3)); + assertThatThrownBy(() -> answerService.submit(noSchool, submit(code, VALUES))) + .isInstanceOf(BizException.class).hasMessageContaining("无学校归属"); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java index 2deb9d1..f52fc80 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java @@ -106,10 +106,10 @@ class AnswerReadIT extends AnswerTestSupport { } assertThat(pages).as("五行未删数据按 size=2 应翻 3 页").isEqualTo(3); assertThat(paged).containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, - rows.proxyForStudent1, rows.teacher1Self, rows.teacher2Self); + rows.proxyForStudent1, rows.teacher1Row, rows.teacher2Row); // 游标用别人范围内的 uuid:不可见即拒(PARAM_ERROR,而不是静默从头开始) - String foreign = rows.teacher1Self; + String foreign = rows.teacher1Row; ReadModels.ListRequest probe = listRequest(rows.formAUuid); probe.setCursor(foreign); probe.setSize(2); @@ -128,10 +128,10 @@ class AnswerReadIT extends AnswerTestSupport { assertThat(visibleIds(fixture, manager(), rows.formAUuid)) .containsExactlyInAnyOrder(rows.student1Self, rows.student2Self, rows.proxyForStudent1, - rows.teacher1Self, rows.teacher2Self); + rows.teacher1Row, rows.teacher2Row); // 教师×面向学生:一期实际生效=仅本人提交(方案 §6.4 表格明示,防被当 bug 报) assertThat(visibleIds(fixture, fixture.teacher1(), rows.formAUuid)) - .containsExactly(rows.teacher1Self); + .containsExactly(rows.teacher1Row); // 学生×面向学生=关于我:自填那条 subject 为空看不见,老师代填的看得见(契约 §6.3 两键分立的直接体现) assertThat(visibleIds(fixture, fixture.student1(), rows.formAUuid)) .containsExactly(rows.proxyForStudent1); @@ -145,7 +145,7 @@ class AnswerReadIT extends AnswerTestSupport { ReadFixture fixture = new ReadFixture(this); ReadFixture.Rows rows = fixture.seed(manager()); BoaUser teacher = fixture.teacher1(); - assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Self); + assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Row); grantSchoolAdmin(ReadFixture.TEACHER_1, SCH); ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid)); @@ -237,7 +237,7 @@ class AnswerReadIT extends AnswerTestSupport { 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); + .containsExactlyInAnyOrder(rows.teacher1Row, rows.teacher2Row); } @Test @@ -276,7 +276,7 @@ class AnswerReadIT extends AnswerTestSupport { // 等值:选项存 value,展示 label 变了也不影响筛选(V2 同族) assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("grade", "g2", null, null))) - .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Self); + .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Row); // 前缀模糊(且不是 LIKE 注入:传 "%" 不会命中全部) assertThat(visibleIdsWithFilter(fixture, manager(), rows.formAUuid, filter("name", "张", null, null))) .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1); @@ -288,7 +288,7 @@ class AnswerReadIT extends AnswerTestSupport { // 日期区间按日期比,不是字典序(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); + .containsExactlyInAnyOrder(rows.student1Self, rows.proxyForStudent1, rows.teacher2Row); ReadModels.ListRequest unknown = listRequest(rows.formAUuid); unknown.setFilters(Arrays.asList(filter("remark_not_exists", "x", null, null))); @@ -395,16 +395,16 @@ class AnswerReadIT extends AnswerTestSupport { ReadFixture fixture = new ReadFixture(this); ReadFixture.Rows rows = fixture.seed(manager()); - assertThatThrownBy(() -> readService.detail(fixture.teacher2(), SCH, rows.teacher1Self)) + assertThatThrownBy(() -> readService.detail(fixture.teacher2(), SCH, rows.teacher1Row)) .isInstanceOf(BizException.class).hasMessageContaining("不在你的数据范围内"); - assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Self)) + assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Row)) .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.setAnswerIds(Arrays.asList(rows.teacher1Row, rows.teacher2Row)); batch.setStatus("pending"); List results = adminService.batchChangeStatus(teacher, SCH, batch); assertThat(results.get(0).getSuccess()).isTrue(); diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java index 28313bd..5fede24 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerSubmitIT.java @@ -240,14 +240,16 @@ class AnswerSubmitIT extends AnswerTestSupport { String uuid = publishForm(limited, SCHEMA); config(SCH, uuid, limitOnce()); Integer sameUser = 10001; - answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES)); - assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 1), submit(limited, VALUES))) + // P95 起教师×面向学生只能代填(第 0 关表层②),这条要的是"subject 为空的自填行", + // 所以提交人换成学生身份——限次谓词(coalesce(subject,submitter))与 roleId 无关,断言一字未改 + answerService.submit(user(sameUser, SCH, 3), submit(limited, VALUES)); + assertThatThrownBy(() -> answerService.submit(user(sameUser, SCH, 3), submit(limited, VALUES))) .hasMessageContaining("限填一次"); String unlimited = "v32_unlimited"; publishForm(unlimited, SCHEMA); - answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES)); - answerService.submit(user(sameUser, SCH, 1), submit(unlimited, VALUES)); + answerService.submit(user(sameUser, SCH, 3), submit(unlimited, VALUES)); + answerService.submit(user(sameUser, SCH, 3), submit(unlimited, VALUES)); assertThat(answerRepository.findBySchIdAndAppCodeAndCodeOrderByIdAsc(SCH, "form-center", unlimited)) .hasSize(2); } diff --git a/src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java b/src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java new file mode 100644 index 0000000..204c3d9 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FillAccessServiceTest.java @@ -0,0 +1,263 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.enums.FormEnums; +import com.yunhai.formcenter.service.FillAccessService.Tier; +import com.yunhai.platform.apistd.BizException; +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 java.util.EnumSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 填报入口层解析单测(自验项 V48:任务书 P95 §0.1 三层模型矩阵逐格 + 多身份并集 + 超管 + + * 两表命中 + NONE 兜底)。 + * + *

桩只顶掉管理员两表来源({@link InMemoryAdminScopeSource},同 {@code DataScopeServiceTest} 先例), + * 判定规则本身全走生产代码。第 0 关的行为(放行/拒绝/文案)不在这里,是 + * {@code AnswerSubmitGateIT} 按 W2 行为表逐行打的。

+ */ +@DisplayName("FillAccessService - P95 三层角色模型入口解析(V48)") +class FillAccessServiceTest { + + private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode(); + private static final int TEACHER_FACING = FormEnums.Audience.TEACHER.getCode(); + private static final long SCH = 990301L; + private static final long OTHER_SCH = 990302L; + + private final InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource(); + private final FillAccessService service = new FillAccessService(admins); + + // ==================== 层① FILL:本人身份可填 ==================== + + @Test + @DisplayName("V48 层①:学生(3)×面向学生=FILL(可填、不可代填);教师(1)×面向教师同理") + void fillTierForMatchingAudience() { + assertThat(service.resolve(user(3), form(SCH, "f-own", STUDENT_FACING))).containsExactly(Tier.FILL); + assertThat(service.resolve(user(1), form(SCH, "f-own", TEACHER_FACING))).containsExactly(Tier.FILL); + assertThat(service.canFill(EnumSet.of(Tier.FILL))).isTrue(); + assertThat(service.canProxy(EnumSet.of(Tier.FILL))).isFalse(); + assertThat(service.isAdmin(EnumSet.of(Tier.FILL))).isFalse(); + } + + // ==================== 层② MANAGE:教师×学生面向,只能代填 ==================== + + @Test + @DisplayName("V48 层②:教师(1)×面向学生=MANAGE(可代填、不可自填)——FILL 谓词不含这一层,正是 W2 行为表'教师自填学生面向=拒'的解析根据") + void manageTierIsProxyOnly() { + Set tiers = service.resolve(user(1), form(SCH, "f-own", STUDENT_FACING)); + assertThat(tiers).containsExactly(Tier.MANAGE); + assertThat(service.canFill(tiers)).as("教师本人身份不能填学生面向表单").isFalse(); + assertThat(service.canProxy(tiers)).as("教师可代填本校学生").isTrue(); + assertThat(service.isAdmin(tiers)).isFalse(); + assertThat(service.topTier(tiers)).isEqualTo(Tier.MANAGE); + } + + // ==================== 层③ ADMIN:四条来源分别命中 ==================== + + @Test + @DisplayName("V48 层③:校领导(2)/管理员(7) 两种人群都是 ADMIN,且 ADMIN 同时给可填与可代填") + void adminRolesAreAdminTier() { + for (int roleId : Arrays.asList(2, 7)) { + assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING))) + .as("roleId=" + roleId + " 面向学生") + .containsExactly(Tier.ADMIN); + assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING))) + .as("roleId=" + roleId + " 面向教师") + .containsExactly(Tier.ADMIN); + Set tiers = service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING)); + assertThat(service.canFill(tiers)).isTrue(); + assertThat(service.canProxy(tiers)).isTrue(); + assertThat(service.isAdmin(tiers)).isTrue(); + } + } + + @Test + @DisplayName("V48 层③:超管(type==1,不是 roleId)恒为 ADMIN,即使没有任何角色") + void superAdminIsAdminTier() { + BoaUser root = new BoaUser(); + root.setId(1); + root.setType(1); + root.setCurrentRole(role(9, (int) SCH)); + assertThat(service.resolve(root, form(SCH, "f-own", TEACHER_FACING))).containsExactly(Tier.ADMIN); + } + + @Test + @DisplayName("V48 层③:school_admin 命中会话校才升 ADMIN;会话切到别校即不命中(授权域跟随会话)") + void schoolAdminFollowsSessionDomain() { + BoaUser teacher = user(1); + admins.grantSchoolAdmin(SCH, teacher.getId()); + assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))) + .containsExactlyInAnyOrder(Tier.ADMIN, Tier.MANAGE); + + BoaUser atOther = user(1); + atOther.setCurrentRole(role(1, (int) OTHER_SCH)); + assertThat(service.resolve(atOther, form(SCH, "f-own", STUDENT_FACING))) + .as("A 校的校级管理员授权不跟到 B 校会话") + .containsExactly(Tier.MANAGE); + } + + @Test + @DisplayName("V48 层③:form_admin 命中该表单 uuid才升 ADMIN,同校未指定的表单不受影响") + void formAdminIsPerForm() { + BoaUser teacher = user(3); + admins.grantFormAdmin("f-yes", teacher.getId()); + assertThat(service.resolve(teacher, form(SCH, "f-yes", STUDENT_FACING))) + .containsExactlyInAnyOrder(Tier.ADMIN, Tier.FILL); + assertThat(service.resolve(teacher, form(SCH, "f-no", STUDENT_FACING))) + .as("没被指定的那张表单仍是普通学生") + .containsExactly(Tier.FILL); + } + + @Test + @DisplayName("V48 层③:无学校归属的会话不查 school_admin,但 form_admin 仍按 uuid 命中(不要求会话校)") + void adminTablesWithoutSchoolBinding() { + BoaUser teacher = user(1); + teacher.setCurrentRole(role(1, null)); + admins.grantSchoolAdmin(SCH, teacher.getId()); + assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))) + .as("给不出会话校就没有本校管理员可认") + .containsExactly(Tier.MANAGE); + + admins.grantFormAdmin("f-own", teacher.getId()); + assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))) + .containsExactlyInAnyOrder(Tier.ADMIN, Tier.MANAGE); + } + + // ==================== NONE 兜底 ==================== + + @Test + @DisplayName("V48 NONE:家长(4)/工作号(6)/局领导(5)/未知角色 = 空集(两种人群都是),三个谓词全 false") + void peripheralRolesResolveToNone() { + for (int roleId : Arrays.asList(4, 6, 5, 99)) { + for (int audience : new int[]{STUDENT_FACING, TEACHER_FACING}) { + Set tiers = service.resolve(user(roleId), form(SCH, "f-own", audience)); + assertThat(tiers).as("roleId=" + roleId + " audience=" + audience).isEmpty(); + assertThat(service.canFill(tiers)).isFalse(); + assertThat(service.canProxy(tiers)).isFalse(); + assertThat(service.isAdmin(tiers)).isFalse(); + assertThat(service.topTier(tiers)).isNull(); + } + } + } + + @Test + @DisplayName("V48 NONE:学生(3)×面向教师=空集(原'不授填报模块行进不来'的格子,现在由中心解析直接判 NONE)") + void studentOnTeacherFacingIsNone() { + assertThat(service.resolve(user(3), form(SCH, "f-own", TEACHER_FACING))).isEmpty(); + } + + @Test + @DisplayName("V48 NONE:roleIds 全不命中(空角色、会话也无 currentRole)=空集,不是兜底给一层") + void noRolesAtAllIsNone() { + BoaUser nobody = new BoaUser(); + nobody.setId(20001); + nobody.setType(0); + nobody.setCurrentRole(role(9, (int) SCH)); + assertThat(service.resolve(nobody, form(SCH, "f-own", STUDENT_FACING))).isEmpty(); + + BoaUser bare = new BoaUser(); + bare.setId(20002); + assertThat(service.resolve(bare, form(SCH, "f-own", STUDENT_FACING))).isEmpty(); + } + + // ==================== 多身份并集 ==================== + + @Test + @DisplayName("V48 并集:同时有学生(3)与教师(1)身份×面向学生 = {FILL, MANAGE}(可填可代填,两格都在不是二选一)") + void teacherAndStudentIdentitiesUnion() { + BoaUser dual = new BoaUser(); + dual.setId(20003); + dual.setType(0); + dual.setRoleIds(Arrays.asList(1, 3)); + dual.setCurrentRole(role(3, (int) SCH)); + Set tiers = service.resolve(dual, form(SCH, "f-own", STUDENT_FACING)); + assertThat(tiers).containsExactlyInAnyOrder(Tier.MANAGE, Tier.FILL); + assertThat(service.canFill(tiers)).isTrue(); + assertThat(service.canProxy(tiers)).isTrue(); + assertThat(service.topTier(tiers)).isEqualTo(Tier.MANAGE); + } + + @Test + @DisplayName("V48 并集:教师×面向教师 = {FILL},同一人换到面向学生的表单 = {MANAGE}(人群换列,身份不变)") + void sameUserDifferentAudienceDifferentTier() { + BoaUser teacher = user(1); + assertThat(service.resolve(teacher, form(SCH, "f-own", TEACHER_FACING))).containsExactly(Tier.FILL); + assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))).containsExactly(Tier.MANAGE); + } + + @Test + @DisplayName("V48 identityRoleIds 口径:roleIds 未含会话身份时补上(UserBase 两侧不一致不漏当前身份那格)") + void currentRoleIsUnionedEvenIfMissingFromRoleIds() { + BoaUser user = new BoaUser(); + user.setId(20004); + user.setType(0); + user.setRoleIds(Collections.singletonList(3)); + user.setCurrentRole(role(7, (int) SCH)); + assertThat(service.resolve(user, form(SCH, "f-own", STUDENT_FACING))) + .as("会话身份=管理员(7) 虽不在 roleIds 里也要进 ADMIN") + .containsExactlyInAnyOrder(Tier.ADMIN, Tier.FILL); + } + + // ==================== 边界与拒绝 ==================== + + @Test + @DisplayName("V48:表单为 null 按面向学生列解析(同 DataScopeService 口径),教师即 MANAGE、学生即 FILL") + void nullFormUsesStudentColumn() { + assertThat(service.resolve(user(1), null)).containsExactly(Tier.MANAGE); + assertThat(service.resolve(user(3), null)).containsExactly(Tier.FILL); + } + + @Test + @DisplayName("V48:audience 缺失按面向学生兜底(列本身 NOT NULL,这里是防脏行不静默开到教师面向)") + void nullAudienceFallsBackToStudentColumn() { + Form broken = new Form(); + broken.setSchId(SCH); + broken.setUuid("f-broken"); + assertThat(service.resolve(user(3), broken)).containsExactly(Tier.FILL); + assertThat(service.resolve(user(1), broken)).containsExactly(Tier.MANAGE); + } + + @Test + @DisplayName("V48:未登录不解析(拒绝,而不是给一个兜底层)") + void requiresSession() { + assertThatThrownBy(() -> service.resolve(null, form(SCH, "f-own", STUDENT_FACING))) + .isInstanceOf(BizException.class).hasMessageContaining("未登录"); + assertThatThrownBy(() -> service.resolve(new BoaUser(), form(SCH, "f-own", STUDENT_FACING))) + .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, (int) SCH)); + return user; + } + + private BoaRoleInfo role(Integer roleId, Integer schId) { + BoaRoleInfo info = new BoaRoleInfo(); + info.setRoleId(roleId); + info.setSchId(schId); + return info; + } + + private Form form(long schId, String uuid, int audience) { + Form form = new Form(); + form.setSchId(schId); + form.setUuid(uuid); + form.setAudience(audience); + return form; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/FillableListIT.java b/src/test/java/com/yunhai/formcenter/service/FillableListIT.java new file mode 100644 index 0000000..d1c961b --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FillableListIT.java @@ -0,0 +1,262 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.model.MyModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +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 org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 「我可填」列表接口集成测试(自验项 V50):域遮蔽去重、仅已发布、appCode 过滤、 + * canFill/myTier、排序——四条都打真库({@code @Transactional} 回滚)。 + * + *

服务方法直调(同 {@code MyAnswerIT} 先例,不起 MockMvc):这一把要证的是取域/去重/过闸/排序四件事, + * 全在服务内;信封与参数绑定是 {@code FormBaseControllerTest} 那类已在别处验过的壳。

+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("FillableFormService - 我可填列表(V50)") +class FillableListIT extends AnswerTestSupport { + + @org.springframework.beans.factory.annotation.Autowired + private FillableFormService fillableService; + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}"; + + /** 快应用面(方案 §6.2):中心之外的 app_code,用来证 appCode 过滤与"列表跨应用"两件事 */ + private static final String QUICK_APP = "fc-drill"; + + // ==================== 入口层过滤 ==================== + + @Test + @DisplayName("V50 canFill/myTier:学生×面向学生=(true,FILL)、教师×面向学生=(false,MANAGE)、教师×面向教师=(true,FILL)、管理员=(true,ADMIN)") + void rowsCarryEntryLayerVerdicts() { + publishAt(SCH, Form.CENTER_APP_CODE, "v50_student", "面向学生的表", 1, null); + publishAt(SCH, Form.CENTER_APP_CODE, "v50_teacher", "面向教师的表", 2, null); + + List asStudent = fillableService.listFillable(user(30001, SCH, 3), null); + assertThat(asStudent).extracting(MyModels.FillableRow::getCode).containsExactly("v50_student"); + assertThat(asStudent.get(0).getCanFill()).isTrue(); + assertThat(asStudent.get(0).getMyTier()).isEqualTo("FILL"); + + List asTeacher = fillableService.listFillable(user(30002, SCH, 1), null); + assertThat(asTeacher).extracting(MyModels.FillableRow::getCode) + .as("教师两张表都入集:学生面向那张只能代填,但数据管理 tab 要靠它") + .containsExactlyInAnyOrder("v50_student", "v50_teacher"); + assertThat(row(asTeacher, "v50_student").getCanFill()) + .as("层②:入口给开(可代填),但本人不能自填") + .isFalse(); + assertThat(row(asTeacher, "v50_student").getMyTier()).isEqualTo("MANAGE"); + assertThat(row(asTeacher, "v50_teacher").getCanFill()).isTrue(); + assertThat(row(asTeacher, "v50_teacher").getMyTier()).isEqualTo("FILL"); + + // 层③:管理员(7) 两种人群都可填,myTier=ADMIN + List asAdmin = fillableService.listFillable(user(30003, SCH, 7), null); + assertThat(asAdmin).hasSize(2); + assertThat(asAdmin).allMatch(r -> Boolean.TRUE.equals(r.getCanFill())); + assertThat(asAdmin).allMatch(r -> "ADMIN".equals(r.getMyTier())); + } + + @Test + @DisplayName("V50 NONE 不入集:家长(4)/局领导(5)/工作号(6) 拿到空列表(不是报错、也不是全给)") + void peripheralRolesGetEmptyList() { + publishAt(SCH, Form.CENTER_APP_CODE, "v50_none", "任意表单", 1, null); + for (int roleId : Arrays.asList(4, 5, 6)) { + assertThat(fillableService.listFillable(user(30100 + roleId, SCH, roleId), null)) + .as("roleId=" + roleId) + .isEmpty(); + } + } + + @Test + @DisplayName("V50 两表命中:进了 form_admin 的学生在该表单上升到 ADMIN;school_admin 命中会话校则本校全部表单 ADMIN") + void adminTablesLiftTierInList() { + String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_admin_tables", "两表用例", 1, null); + publishAt(SCH, Form.CENTER_APP_CODE, "v50_other", "另一张", 1, null); + BoaUser student = user(30004, SCH, 3); + + grantFormAdmin(uuid, student.getId(), SCH); + List afterFormAdmin = fillableService.listFillable(student, null); + assertThat(row(afterFormAdmin, "v50_admin_tables").getMyTier()).isEqualTo("ADMIN"); + assertThat(row(afterFormAdmin, "v50_other").getMyTier()) + .as("未指定的那张仍是普通学生档") + .isEqualTo("FILL"); + + grantSchoolAdmin(student.getId(), SCH); + assertThat(fillableService.listFillable(student, null)).allMatch(r -> "ADMIN".equals(r.getMyTier())); + } + + // ==================== 域与遮蔽去重 ==================== + + @Test + @DisplayName("V50 域:会话校 ∪ 平台级(sch_id=0) 都在集内;同 (app_code, code) 本校行遮蔽平台行(只出一行、取本校的 name)") + void domainIsSchoolUnionPlatformWithSchoolWinning() { + publishAt(Form.PLATFORM_SCH_ID, Form.CENTER_APP_CODE, "v50_shadow", "平台级的那张", 1, null); + publishAt(SCH, Form.CENTER_APP_CODE, "v50_shadow", "本校的那张", 1, null); + publishAt(Form.PLATFORM_SCH_ID, Form.CENTER_APP_CODE, "v50_base_only", "只有平台级", 1, null); + + List rows = fillableService.listFillable(user(30005, SCH, 3), null); + assertThat(rows).extracting(MyModels.FillableRow::getCode) + .containsExactlyInAnyOrder("v50_shadow", "v50_base_only"); + assertThat(row(rows, "v50_shadow").getName()) + .as("遮蔽规则与 resolve 同源:本校行胜出且不回落到平台行") + .isEqualTo("本校的那张"); + } + + @Test + @DisplayName("V50 他校表单不进集(同 code 在他校存在,本校会话看不到)") + void otherSchoolFormsAreNotInDomain() { + publishAt(OTHER_SCH, Form.CENTER_APP_CODE, "v50_foreign", "他校的表", 1, null); + assertThat(fillableService.listFillable(user(30006, SCH, 7), null)).isEmpty(); + } + + // ==================== 生命周期 ==================== + + @Test + @DisplayName("V50 仅已发布:草稿、停用、软删三种状态都不入集(与 resolve 的填报入口口径一致)") + void onlyPublishedAndLiveFormsAreListed() { + FormModels.FormCreateRequest draft = new FormModels.FormCreateRequest(); + draft.setAppCode(Form.CENTER_APP_CODE); + draft.setCode("v50_draft"); + draft.setName("草稿"); + String draftUuid = defService.create(SCH, draft).getFormUuid(); + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(draftUuid); + save.setSchemaJson(SCHEMA); + defService.save(SCH, save); + + String disabledUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_disabled", "停用的", 1, null); + defService.disable(SCH, disabledUuid); + String deletedUuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_deleted", "软删的", 1, null); + defService.softDelete(SCH, deletedUuid); + publishAt(SCH, Form.CENTER_APP_CODE, "v50_live", "正常发布", 1, null); + + assertThat(fillableService.listFillable(user(30007, SCH, 3), null)) + .extracting(MyModels.FillableRow::getCode).containsExactly("v50_live"); + } + + // ==================== appCode 过滤 ==================== + + @Test + @DisplayName("V50 appCode:缺省=全部应用(中心 + 快应用都入集);传 appCode 只收窄到该应用,中心行不外泄") + void appCodeFilterNarrowsTheList() { + publishAt(SCH, Form.CENTER_APP_CODE, "v50_center", "中心的表", 1, null); + publishAt(SCH, QUICK_APP, "v50_quick", "快应用的表", 1, null); + BoaUser student = user(30008, SCH, 3); + + assertThat(fillableService.listFillable(student, null)) + .as("缺省=全部应用") + .extracting(MyModels.FillableRow::getCode).containsExactly("v50_center", "v50_quick"); + assertThat(fillableService.listFillable(student, QUICK_APP)) + .extracting(MyModels.FillableRow::getCode).containsExactly("v50_quick"); + assertThat(fillableService.listFillable(student, Form.CENTER_APP_CODE)) + .extracting(MyModels.FillableRow::getCode).containsExactly("v50_center"); + assertThat(fillableService.listFillable(student, " ")) + .as("空白串按缺省处理(=全部应用),不按 app_code=' ' 去查") + .extracting(MyModels.FillableRow::getCode).containsExactly("v50_center", "v50_quick"); + } + + // ==================== 排序与行形状 ==================== + + @Test + @DisplayName("V50 排序:deadline 升序、不限截止的排最后、同档按 code 升序") + void sortedByDeadlineThenCodeWithNullDeadlineLast() { + publishAt(SCH, Form.CENTER_APP_CODE, "b_late", "晚截止", 1, "2026-12-01"); + publishAt(SCH, Form.CENTER_APP_CODE, "a_early", "早截止", 1, "2026-11-01"); + publishAt(SCH, Form.CENTER_APP_CODE, "z_none", "不限截止", 1, null); + publishAt(SCH, Form.CENTER_APP_CODE, "c_same_1", "同档一", 1, "2026-11-15"); + publishAt(SCH, Form.CENTER_APP_CODE, "b_same_2", "同档二", 1, "2026-11-15"); + + assertThat(fillableService.listFillable(user(30009, SCH, 3), null)) + .extracting(MyModels.FillableRow::getCode) + .containsExactly("a_early", "b_same_2", "c_same_1", "b_late", "z_none"); + } + + @Test + @DisplayName("V50 行形状:audience/fillLimit 下发列值、deadline 格式化、groupTag 透出,且不带任何库内 id") + void rowCarriesDefinitionFieldsInContractShape() { + String uuid = publishAt(SCH, Form.CENTER_APP_CODE, "v50_shape", "形状用例", 1, "2026-11-20"); + FormModels.FormConfigRequest config = new FormModels.FormConfigRequest(); + config.setFillLimit(1); + config.setGroupTag("五年级"); + config(SCH, uuid, config); + + MyModels.FillableRow row = row(fillableService.listFillable(user(30010, SCH, 3), null), "v50_shape"); + assertThat(row.getName()).isEqualTo("形状用例"); + assertThat(row.getAppCode()).isEqualTo(Form.CENTER_APP_CODE); + assertThat(row.getAudience()).isEqualTo(1); + assertThat(row.getFillLimit()).isEqualTo(1); + assertThat(row.getGroupTag()).isEqualTo("五年级"); + assertThat(row.getDeadline()).isEqualTo("2026-11-20 23:59:59"); + } + + // ==================== 会话两段 ==================== + + @Test + @DisplayName("V50 会话:未登录=UNAUTHORIZED、无学校归属=FORBIDDEN(沿 my 面现口径,不返回空集糊弄)") + void sessionGuardsFollowMyFaceConvention() { + assertThatThrownBy(() -> fillableService.listFillable(null, null)) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED)); + + BoaUser noSchool = new BoaUser(); + noSchool.setId(30011); + noSchool.setRoleIds(Collections.singletonList(3)); + noSchool.setCurrentRole(role(3, null)); + assertThatThrownBy(() -> fillableService.listFillable(noSchool, null)) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + } + + // ==================== 小工具 ==================== + + /** 建表单→存 schema→发布,可选人群与截止(deadline 只给日期时后端补当日 23:59:59,方案 §5.4) */ + private String publishAt(Long schId, String appCode, String code, String name, Integer audience, String deadline) { + FormModels.FormCreateRequest create = new FormModels.FormCreateRequest(); + create.setAppCode(appCode); + create.setCode(code); + create.setName(name); + String uuid = defService.create(schId, create).getFormUuid(); + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(uuid); + save.setSchemaJson(SCHEMA); + defService.save(schId, save); + defService.publish(schId, uuid, null); + FormModels.FormConfigRequest config = new FormModels.FormConfigRequest(); + config.setAudience(audience); + config.setDeadline(deadline); + config(schId, uuid, config); + return uuid; + } + + private static MyModels.FillableRow row(List rows, String code) { + List hit = rows.stream() + .filter(r -> code.equals(r.getCode())).collect(Collectors.toList()); + assertThat(hit).as("列表里没有 code=" + code).hasSize(1); + return hit.get(0); + } + + private static BoaRoleInfo role(Integer roleId, Integer schId) { + BoaRoleInfo info = new BoaRoleInfo(); + info.setRoleId(roleId); + info.setSchId(schId); + return info; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java index cb9e936..a341562 100644 --- a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java +++ b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java @@ -87,7 +87,7 @@ class MyAnswerIT extends AnswerTestSupport { // ==================== ① 身份全会话推导(V17) ==================== @Test - @DisplayName("V17 结构半边:六端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)") + @DisplayName("V17 结构半边:七端点首参恒为会话用户、无 id 型入参,请求体只有 uuid/值/说明(外部传 id 不可能)") void endpointsCarryNoIdentityParameter() { List mapped = new ArrayList<>(); for (Method m : FormMyAnswerController.class.getDeclaredMethods()) { @@ -107,7 +107,9 @@ class MyAnswerIT extends AnswerTestSupport { } assertThat(mapped).containsExactlyInAnyOrder( "GET /list", "GET /submit-list", "GET /about-list", "GET /detail", - "POST /delete", "POST /edit"); + "POST /delete", "POST /edit", + // P95 W3 新增的填报入口清单:同样只吃会话身份(appCode 是应用维度筛项,不是身份) + "GET /fillable"); // V14 的结构半边:本面没有任何改状态端点 assertThat(mapped).noneMatch(p -> p.contains("status")); assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId"); diff --git a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java index debfff3..93b57d7 100644 --- a/src/test/java/com/yunhai/formcenter/service/ReadFixture.java +++ b/src/test/java/com/yunhai/formcenter/service/ReadFixture.java @@ -54,6 +54,12 @@ class ReadFixture { static final int TEACHER_1 = 20264101; static final int TEACHER_2 = 20264102; static final int BUREAU_LEADER = 20264201; + /** + * 教师代填的被代填人(P95 起教师×面向学生表单只能代填,自填被第 0 关拒)。 + * 刻意用两个新 id:它们不落在任何"关于我"断言的主体上,学生视角的可见集因此不因造数据而变大。 + */ + static final int TEACHER_PROXY_1 = 20264003; + static final int TEACHER_PROXY_2 = 20264004; private final AnswerTestSupport support; @@ -69,10 +75,10 @@ class ReadFixture { 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; + /** 教师1 提交的那条(P95 起=他代填 TEACHER_PROXY_1,submitter=教师1):grade g3 / score 95 / advisor 张老师 */ + String teacher1Row; + /** 教师2 提交的那条(同上,代填 TEACHER_PROXY_2):grade g2 / score 60 / advisor 张老师 */ + String teacher2Row; /** 学生2 自填后被软删(四条读路径都看不见) */ String softDeleted; /** 表单 B(面向教师)上教师1 自填的一条 */ @@ -105,10 +111,10 @@ class ReadFixture { rows.proxyForStudent1 = submitProxy(admin, FORM_A, STUDENT_1, "{\"name\":\"张三(老师代填)\",\"grade\":\"g2\",\"score\":70,\"birthday\":\"2010-03-09\"," + "\"advisor\":\"李老师\"}"); - rows.teacher1Self = submit(teacher1(), FORM_A, + rows.teacher1Row = submitProxy(teacher1(), FORM_A, TEACHER_PROXY_1, "{\"name\":\"王五\",\"grade\":\"g3\",\"score\":95,\"birthday\":\"2009-05-20\"," + "\"advisor\":\"张老师\"}"); - rows.teacher2Self = submit(teacher2(), FORM_A, + rows.teacher2Row = submitProxy(teacher2(), FORM_A, TEACHER_PROXY_2, "{\"name\":\"赵六\",\"grade\":\"g2\",\"score\":60,\"birthday\":\"2010-01-15\"," + "\"advisor\":\"张老师\"}"); rows.softDeleted = submit(student2(), FORM_A,