From e4bf960e1b8be2c1ceb2a421ac205f527c7fb634 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 13:20:58 +0800 Subject: [PATCH] =?UTF-8?q?t14:=E6=A8=A1=E5=9D=97=E8=A1=8C=E9=80=80?= =?UTF-8?q?=E5=BD=B9=20+=20=E8=8F=9C=E5=8D=95=E8=87=AA=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=AE=88=E5=8D=AB=E6=8D=A2=20C4=20=E4=B8=89?= =?UTF-8?q?=E6=AE=B5=E8=A7=92=E8=89=B2=E9=97=A8=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20managed/capabilities=20=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormAdminGuard 整体重写:requireCreate(超管∪校管理员,G1)/requireDefinition(+该表单创建人) /requireData(该表单 ALL 档)/requirePlatformAdmin(校管理员∪超管);答卷寻址的入口 先由 uuid 反查所属表单再判同一道门(requireDataByAnswer/requireDataByAnswers); 租户域推导(超管可指定含 0、校级只能本校、传 0 即拒)一字未改 - 摘除 ModuleTreeSource/PlatformModuleTreeSource/FormModuleKeys 与 GetUserMod 端点 (§19 作废-10:中心不再消费授权树,B14 缺口与 H4 依赖随之消失) - 新增 GET /api/form/my/managed(C3:创建人 ∪ form_admin,role 标来源)与 GET /api/form/my/capabilities({schoolAdmin},"系统设置"入口唯一数据源); managed 行额外带 formUuid/status(P120:只给 C3 的"fillable 行+role"点不进设计器/数据页) - 两表/app 注册/GetSchoolAll 守卫切到 requirePlatformAdmin;字典同样吃这一道门 - h2_userbase_app_and_modules.sql 文件头加"整份作废、不再在任何环境执行"注记(正文保留供追溯) - 测试:FormAdminGuardTest 按三段门重写(含批量入口跨表逐张判 ALL、fail-closed 与域推导); FormBaseControllerTest 改成"GetUserMod 端点反射级不存在"断言;AdminDomainIT/MyAnswerIT/ AnswerAdminIT/AnswerReadIT 的模块行断言换角色门载体;PerfTreeCallsProbe(P44 取树次数) 随度量对象消失而删除 --- sql/h2_userbase_app_and_modules.sql | 10 + .../formcenter/common/FormModuleKeys.java | 55 --- .../controller/api/AdminDomainController.java | 21 +- .../controller/api/FormAdminController.java | 41 +- .../api/FormAnswerAdminController.java | 18 +- .../api/FormAnswerReadController.java | 23 +- .../controller/api/FormBaseController.java | 128 +---- .../api/FormMyAnswerController.java | 40 +- .../yunhai/formcenter/entity/FormAdmin.java | 2 +- .../yunhai/formcenter/entity/SchoolAdmin.java | 3 +- .../com/yunhai/formcenter/model/MyModels.java | 38 ++ .../yunhai/formcenter/model/ReadModels.java | 2 +- .../repository/AnswerRepository.java | 7 + .../repository/FormAdminRepository.java | 6 + .../formcenter/repository/FormRepository.java | 13 + .../formcenter/service/AdminScopeSource.java | 4 +- .../service/AnswerAdminService.java | 10 +- .../formcenter/service/AnswerReadService.java | 14 +- .../service/AnswerScopeQueryBuilder.java | 4 +- .../formcenter/service/DataScopeService.java | 17 +- .../service/FillableFormService.java | 63 ++- .../formcenter/service/FormAdminGuard.java | 353 ++++++++------ .../formcenter/service/FormDefService.java | 2 +- .../formcenter/service/ModuleTreeSource.java | 29 -- .../service/PlatformModuleTreeSource.java | 40 -- .../api/FormBaseControllerTest.java | 137 ++---- .../formcenter/service/AdminDomainIT.java | 51 +- .../formcenter/service/AdminScopeIT.java | 30 +- .../formcenter/service/AnswerAdminIT.java | 13 +- .../formcenter/service/AnswerReadIT.java | 22 +- .../formcenter/service/AnswerTestSupport.java | 33 +- .../service/FormAdminGuardTest.java | 447 ++++++++++-------- .../service/InMemoryModuleTreeSource.java | 70 --- .../yunhai/formcenter/service/MyAnswerIT.java | 12 +- .../formcenter/service/Perf200kProbe.java | 52 +- .../service/PerfTreeCallsProbe.java | 222 --------- 36 files changed, 849 insertions(+), 1183 deletions(-) delete mode 100644 src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java delete mode 100644 src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java delete mode 100644 src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java delete mode 100644 src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java delete mode 100644 src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java diff --git a/sql/h2_userbase_app_and_modules.sql b/sql/h2_userbase_app_and_modules.sql index ffe0582..fac6e7d 100644 --- a/sql/h2_userbase_app_and_modules.sql +++ b/sql/h2_userbase_app_and_modules.sql @@ -1,4 +1,14 @@ -- ============================================================================ +-- ⛔ 契约 v2 终态(T14,2026-10-21 用户裁决落地):**本脚本整份作废,不再在任何环境执行**。 +-- 依据=《表单中心契约v2-权限简化与开放接入.md》§2.4(D5:UserBase 模块行整条退役) +-- 与 §19 作废-10:中心五行 customId 200~204、300 段快应用行、组授权全部不再登记; +-- 后端 `GetUserMod` 端点与 `ModuleTreeSource` 已删除,左导航改由三支路自解析 +-- (`/api/form/my/fillable`、`/my/managed`、`/my/capabilities`)。 +-- H2 登记事项永久关闭(含"超管加 /f/honor_student 模块行"这条遗留项)。 +-- 下方原文**按用户要求整份保留供追溯**(不作删除),其中的"执行/复核"步骤一律不再执行; +-- UserBase 侧存量行(200~204 与 300 段)的摘除属 P-C 确认后的用户侧动作,见《执行手册》§5 与本批报告。 +-- ============================================================================ +-- -- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本 -- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役) -- 改版:**P95(2026-10-16)快应用段 5→4 行——`/f/{code}` 填报行及其组授权整条撤销** diff --git a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java deleted file mode 100644 index 0fbb738..0000000 --- a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java +++ /dev/null @@ -1,55 +0,0 @@ -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 第②层)。 - * - *

只收 {@code path} 非空的页面路由行:管理权按它判(手册 §2.2 表)。{@code path} 空的行是 - * 纯权限位,不进导航——中心应用 v1.5 起不再有这种行,该形态只存在于快应用面的按钮锚点 - * (导入/导出,由前端 {@code modulePerms} 直读原始树 customId 消费,后端不参与)。

- * - *

数据范围升级位(UserBase 侧的 customId 锚点协议)已在 T10 退役,载体换成管理员两表 - * ({@code AdminScopeSource},管理员体系方案 §5.1/§7),所以本类不再提供 customId 侧的取键方法。

- * - *

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

- */ -public final class FormModuleKeys { - - private FormModuleKeys() { - } - - /** 授权页面路由集合(path 空的权限位不在内) */ - public static Set paths(List tree) { - Set result = new LinkedHashSet<>(); - collect(tree, result); - return result; - } - - /** - * 递归展平。取不出 path 的节点自然被跳过,子节点照常下钻(页面行可以挂在分组层下面)。 - */ - private static void collect(List nodes, Set out) { - if (nodes == null) { - return; - } - for (BoaModule node : nodes) { - if (node == null) { - continue; - } - String path = trimToNull(node.getPath()); - if (path != null) { - out.add(path); - } - collect(node.getChildren(), out); - } - } - - private static String trimToNull(String value) { - return value == null || value.trim().isEmpty() ? null : value.trim(); - } -} diff --git a/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java b/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java index 9328baa..e64282b 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java @@ -19,11 +19,11 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; /** - * 管理员两表的维护端点(T10 方案 §5.3):校级表单管理员三端点摊 {@code /admin/app}(应用设置行), - * 单表单管理员三端点摊 {@code /admin/forms}(表单设计行)。 + * 管理员两表的维护端点(T10 方案 §5.3;契约 v2 C4③ 换守卫)。 * - *

这两组端点属于维护面,不是数据面:谁能进角色设置页由 UserBase 模块行决定(守卫红线—— - * 管理员表本身不作入口凭证),加进去的人只升级数据范围({@code DataScopeService})。 + *

这两组端点属于维护面,不是数据面:谁能进角色设置页由 {@code FormAccessPolicy.isSchoolAdmin} + * 决定(校管理员 ∪ 超管),加进去的人只升级数据范围({@code DataScopeService})—— + * 原口径"两表不作入口凭证"仍然成立,只是入口判据从 UserBase 模块行换成了本库角色。 * 目标域一律走 {@code FormAdminGuard} 的会话域校验:校级只本校、超管可显式指定(含代查任意校), * 名单里的人再逐个复核归属({@code AdminDomainService})。

* @@ -46,14 +46,13 @@ public class AdminDomainController { public List listSchoolAdmins(BoaUser user, @RequestParam(required = false) Long schId) { return adminService.listSchoolAdmins( - adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS)); + adminGuard.requirePlatformAdmin(user, schId)); } @PostMapping("/school-admins") public List addSchoolAdmins(BoaUser user, @RequestBody AdminModels.AdminAddRequest body) { - Long domain = adminGuard.requireAdmin(user, body == null ? null : body.getSchId(), - FormAdminGuard.MODULE_APP_SETTINGS); + Long domain = adminGuard.requirePlatformAdmin(user, body == null ? null : body.getSchId()); return adminService.addSchoolAdmins(domain, body == null ? null : body.getUsers()); } @@ -61,7 +60,7 @@ public class AdminDomainController { public List removeSchoolAdmin(BoaUser user, @RequestParam(required = false) Long schId, @PathVariable("id") Long id) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS); + Long domain = adminGuard.requirePlatformAdmin(user, schId); return adminService.removeSchoolAdmin(domain, id); } @@ -72,7 +71,7 @@ public class AdminDomainController { public List listFormAdmins(BoaUser user, @RequestParam(required = false) Long schId, @PathVariable("formUuid") String formUuid) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN); + Long domain = adminGuard.requirePlatformAdmin(user, schId); return adminService.listFormAdmins(requireForm(domain, formUuid)); } @@ -81,7 +80,7 @@ public class AdminDomainController { @RequestParam(required = false) Long schId, @PathVariable("formUuid") String formUuid, @RequestBody AdminModels.AdminAddRequest body) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN); + Long domain = adminGuard.requirePlatformAdmin(user, schId); Form form = requireForm(domain, formUuid); return adminService.addFormAdmins(form, domain, body == null ? null : body.getUsers()); } @@ -91,7 +90,7 @@ public class AdminDomainController { @RequestParam(required = false) Long schId, @PathVariable("formUuid") String formUuid, @PathVariable("id") Long id) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN); + Long domain = adminGuard.requirePlatformAdmin(user, schId); Form form = requireForm(domain, formUuid); return adminService.removeFormAdmin(form, id); } 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 c7a99b5..9f368e2 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java @@ -45,21 +45,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, FormAdminGuard.MODULE_FORM_DESIGN), appCode, status); + return defService.list(adminGuard.requireCreate(user, schId), 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, FormAdminGuard.MODULE_FORM_DESIGN), uuid); + return defService.get(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid); } @GetMapping("/versions") public List versions(BoaUser user, @RequestParam(required = false) Long schId, @RequestParam("uuid") String uuid) { - return defService.versions(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); + return defService.versions(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid); } /** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */ @@ -68,7 +68,7 @@ public class FormAdminController { @RequestParam(required = false) Long schId, @RequestParam("uuid") String uuid, @RequestParam("version") Integer version) { - return defService.version(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, version); + return defService.version(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid, version); } /** @@ -80,7 +80,7 @@ public class FormAdminController { public List filterFields(BoaUser user, @RequestParam(required = false) Long schId, @RequestParam("uuid") String uuid) { - FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); + FormModels.VersionView current = currentPublishedVersion(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid); return current == null ? java.util.Collections.emptyList() : new java.util.ArrayList<>(FormSchemaValidator.filterableFields(current.getSchemaJson()).keySet()); } @@ -89,7 +89,7 @@ public class FormAdminController { public FormModels.FormView create(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody FormModels.FormCreateRequest body) { - return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), + return defService.create(adminGuard.requireCreate(user, schId), body, creatorId(user)); } @@ -104,7 +104,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, FormAdminGuard.MODULE_FORM_DESIGN), body); + return defService.save(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body); } /** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */ @@ -113,7 +113,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, FormAdminGuard.MODULE_FORM_DESIGN), uuid, changeLog); + return defService.publish(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid, changeLog); } /** 停用:只断填报入口,数据可读可导(方案 §5.4) */ @@ -121,7 +121,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, FormAdminGuard.MODULE_FORM_DESIGN), uuid); + return defService.disable(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid); } /** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */ @@ -129,7 +129,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, FormAdminGuard.MODULE_FORM_DESIGN), uuid); + return defService.enable(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid); } /** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */ @@ -137,7 +137,7 @@ public class FormAdminController { public void softDelete(BoaUser user, @RequestParam(required = false) Long schId, @RequestParam("uuid") String uuid) { - defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid); + defService.softDelete(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid); } /** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行;创建人=点副本的人 */ @@ -145,7 +145,8 @@ 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, FormAdminGuard.MODULE_FORM_DESIGN), + return defService.copy(adminGuard.requireDefinition(user, schId, + defService.findFormForGuard(body.getUuid())), body, creatorId(user)); } @@ -154,7 +155,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, FormAdminGuard.MODULE_FORM_DESIGN), body); + return defService.updateConfig(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body); } /** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */ @@ -162,7 +163,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, FormAdminGuard.MODULE_FORM_DESIGN), body); + return defService.updateStatusDefs(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body); } // ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ==================== @@ -170,36 +171,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, FormAdminGuard.MODULE_APP_SETTINGS)); + return dictService.listOwn(adminGuard.requirePlatformAdmin(user, schId)); } /** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */ @GetMapping("/dict/selectable") public List dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) { - return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS)); + return dictService.listSelectable(adminGuard.requirePlatformAdmin(user, schId)); } @PostMapping("/dict/save") public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { - return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode(), body.getName()); + return dictService.saveDict(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getCode(), body.getName()); } @PostMapping("/dict/delete") public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { - dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode()); + dictService.deleteDict(adminGuard.requirePlatformAdmin(user, body.getSchId()), 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(), FormAdminGuard.MODULE_APP_SETTINGS), + return dictService.saveItem(adminGuard.requirePlatformAdmin(user, body.getSchId()), 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(), FormAdminGuard.MODULE_APP_SETTINGS), body.getDictCode(), body.getValue()); + dictService.deleteItem(adminGuard.requirePlatformAdmin(user, body.getSchId()), 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 a753318..7a44e78 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,11 @@ import java.util.List; * 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。 * *

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

* - *

两层权限(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data}, - * 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次行级数据范围 + *

两层权限(T4 / P30 收紧点;契约 v2 C4②):控制器按该表单 ALL 档把关 + * (答卷寻址的端点先反查所属表单再判),决定"能不能用数据管理接口";服务内每条答卷再过一次行级数据范围 * (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动, * 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。

@@ -41,7 +41,7 @@ public class FormAnswerAdminController { public void edit(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody AnswerModels.EditRequest body) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataByAnswer(user, schId, body == null ? null : body.getAnswerId()); adminService.edit(user, domain, body); } @@ -50,7 +50,7 @@ public class FormAnswerAdminController { public void softDelete(BoaUser user, @RequestParam(required = false) Long schId, @RequestParam("answerId") String answerId) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataByAnswer(user, schId, answerId); adminService.softDelete(user, domain, answerId); } @@ -59,7 +59,7 @@ public class FormAnswerAdminController { public void changeStatus(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody AnswerModels.ChangeStatusRequest body) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataByAnswer(user, schId, body == null ? null : body.getAnswerId()); adminService.changeStatus(user, domain, body); } @@ -68,7 +68,8 @@ public class FormAnswerAdminController { public List batchSoftDelete(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody AnswerModels.BatchRequest body) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataByAnswers(user, schId, + body == null ? java.util.Collections.emptyList() : body.getAnswerIds()); return adminService.batchSoftDelete(user, domain, body); } @@ -77,7 +78,8 @@ public class FormAnswerAdminController { public List batchChangeStatus(BoaUser user, @RequestParam(required = false) Long schId, @RequestBody AnswerModels.BatchRequest body) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataByAnswers(user, schId, + body == null ? java.util.Collections.emptyList() : body.getAnswerIds()); 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 index ecb0437..0f2e1d6 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java @@ -28,16 +28,16 @@ import java.util.List; /** * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。 * - *

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

+ *

权限两层各管一件事,缺一不可(契约 v2 C4② 换了第①层的判据来源):

*
    - *
  • 能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data} - * 或统计 {@code /admin/stats} 任一,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面; - * 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);
  • - *
  • 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1; + *
  • 能不能进这个接口=该表单 ALL 档({@link FormAdminGuard#requireData}; + * 统计/列表/筛选/导出都吃同一道门。详情按 answerId 寻址、入口时手上还没有表单对象, + * 故先由 uuid 反查所属表单再判({@code requireDataByAnswer});
  • + *
  • 进来后能看哪些行=数据范围({@link DataScopeService} 两档解析 + 创建人/两表/角色, * 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。
  • *
* - *

所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校—— + *

所以"拿到 ALL 档入口的人"看到的仍是他范围内的数据——ALL 档在平台级表单上会收窄成本校提交行—— * 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。

* *

身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径); @@ -62,7 +62,7 @@ public class FormAnswerReadController { @RequestParam(required = false) Long schId, @RequestBody ReadModels.ListRequest body) { requireFormUuid(body.getFormUuid()); - Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid())); + Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid())); return readService.list(user, domain, body); } @@ -73,8 +73,7 @@ public class FormAnswerReadController { @RequestParam("answerId") String answerId) { // 入口时只有答卷 uuid、表单还没定位,故按"数据管理或统计任一"取域; // 域内可见性由 readService.detail 里的同一份范围规格 + visible() 行级把关(与列表同集,V27) - Long domain = adminGuard.requireAdminAny(user, schId, - FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataByAnswer(user, schId, answerId); return readService.detail(user, domain, answerId); } @@ -84,7 +83,7 @@ public class FormAnswerReadController { @RequestParam(required = false) Long schId, @RequestParam("formUuid") String formUuid) { requireFormUuid(formUuid); - Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(formUuid)); + Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid)); return readService.filterFields(domain, formUuid); } @@ -94,7 +93,7 @@ public class FormAnswerReadController { @RequestParam(required = false) Long schId, @RequestBody ReadModels.QueryRequest body) { requireFormUuid(body.getFormUuid()); - Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid())); + Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid())); return statsService.stats(user, domain, body); } @@ -111,7 +110,7 @@ public class FormAnswerReadController { @RequestBody ReadModels.QueryRequest body, HttpServletResponse response) throws IOException { requireFormUuid(body.getFormUuid()); - Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid())); + Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid())); String fileName = "form-answers.xlsx"; response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java index 84555aa..3d54aef 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java @@ -3,54 +3,35 @@ package com.yunhai.formcenter.controller.api; import com.yunhai.formcenter.model.SchoolModels; import com.yunhai.formcenter.service.FormAdminGuard; import com.yunhai.formcenter.service.SchoolUserTreeService; -import com.yunhai.platform.openapi.base.YschBaseOpenApi; -import com.yunhai.platform.utils.model.BoaModule; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** - * 前端壳基础端点(对齐 UserBase BaseController 的 /api/base/* 形态):GetUserInfo / GetUserMod。 - *

GetUserMod 与其他子应用(Notice / svc-hall / 事项库 Cms / flow-center)统一走平台下发:经 SDK - * {@code YschBaseOpenApi.getUserModule} 取当前用户在本应用({@code yh.app-auth.app-key})下的 - * 已授权模块树,层级由平台契约 {@code BoaModule.children} 承载,本侧仅映射为前端 layout - * 期望的字段形状({@code page/modId/children})。 - *

target appCode(快应用上下文,方案 §6.2 / 任务书 T8):{@code /app/{appCode}/} 需要的是 - * 那个快应用的模块树,而不是表单中心自己的。它依赖 UserBase {@code /oapi/user/modules} 的 - * 可选 appCode 参数(跨项目触点 H4),SDK 侧目前只有 - * {@code getUserModule(int userId, Integer schId)} 一个形态、无 appCode 入参。 - * H4 上线前本端点对该请求返回空树并打 warn——不静默返回本应用菜单, - * 那会把「请求 A 应用的树」伪装成「A 应用就是这些菜单」。 + * 前端壳基础端点(对齐 UserBase BaseController 的 {@code /api/base/*} 形态)。 + * + *

契约 v2 §2.4 之后本类只剩两个端点:

+ *
    + *
  • {@code GetUserInfo}——会话用户本体,App.vue 引导的唯一请求(左导航改由三支路能力位自解析: + * {@code /api/form/my/fillable}、{@code /api/form/my/managed}、{@code /api/form/my/capabilities});
  • + *
  • {@code GetSchoolAll}——本校组织树(教师版),管理员两表选人弹窗的数据源; + * 守卫从"应用设置 ∪ 表单设计两条模块行"换成 C4③ 的校管理员 ∪ 超管。
  • + *
+ * + *

{@code GetUserMod} 已随模块行整条退役而删除(§19 作废-10):中心不再消费 UserBase 的 + * 授权模块树,B14(快应用目标应用树缺口)与跨项目触点 H4 随之消失——UserBase 侧不需要为表单中心 + * 登记任何模块行,也不需要给 {@code /oapi/user/modules} 加 appCode 参数。

*/ -@Slf4j @RestController @RequiredArgsConstructor public class FormBaseController { - /** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */ - private static final String APP_CODE = "form-center"; - private final FormAdminGuard adminGuard; private final SchoolUserTreeService schoolUserTreeService; - /** - * appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。 - * 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。 - */ - static boolean isForeignTarget(String appCode) { - String target = appCode == null ? "" : appCode.trim(); - return !target.isEmpty() && !APP_CODE.equalsIgnoreCase(target); - } - @GetMapping("/api/base/GetUserInfo") public BoaUser getUserInfo(BoaUser authUserWithDept) { return authUserWithDept; @@ -59,88 +40,13 @@ public class FormBaseController { /** * 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。 * - *

T10 落地的原因:管理员两表的选人入口要"限本校树"(方案 §5.3 末段),而 UserBase 侧 - * 没有给子应用开放"按 app 过滤的组织树"端点,平台现行做法就是子应用自己代理组树——notice 与 - * flow-center 各有一份,本端点是同一口径的第三份({@code SchoolUserTreeService})。 - * 域只从会话推导({@code schId} 不是入参),持应用设置或表单设计任一模块行才能取树。

+ *

没有 {@code schId} 入参:域只从会话推导(契约 §7 身份行),因此候选天然限在本校树里 + * (跨校的人在接口层就给不出来)。T10 补件的原因是 UserBase 侧没有"按 app 过滤的组织树"端点, + * 平台现行做法就是子应用自己代理组树——notice 与 flow-center 各有一份,本端点是同一口径的第三份。

*/ @GetMapping("/api/base/GetSchoolAll") public List getSchoolAll(BoaUser authUserWithDept) { - Long domain = adminGuard.requireAdminAny(authUserWithDept, null, - FormAdminGuard.MODULE_APP_SETTINGS, FormAdminGuard.MODULE_FORM_DESIGN); + Long domain = adminGuard.requirePlatformAdmin(authUserWithDept, null); return schoolUserTreeService.teacherTree(domain); } - - /** - * 当前用户已授权模块树(左侧菜单)。 - *

依赖 UserBase 侧数据:本应用的 {@code app_module} 行({@code path} 须逐字对上 - * front 路由,见 T5 的 router/routes.ts)+ 角色/组授权,二者缺一即返回空菜单。 - * 这里不保留本地 MENUS 兜底——静默回退会把「平台未配模块/未授权」伪装成正常, - * 与其他子应用口径一致,配置缺失应表现为空菜单并被发现。 - * - * @param appCode 可选,目标应用的 app_code。缺省或等于 {@code form-center} 时返回本应用的树; - * 指向其它应用时走 H4(UserBase 侧尚未上线)——返回空树并打 warn。 - */ - @GetMapping("/api/base/GetUserMod") - public List> getUserMod(BoaUser authUserWithDept, - @RequestParam(required = false) String appCode) { - if (isForeignTarget(appCode)) { - log.warn("[GetUserMod] 请求目标应用模块树 appCode={},UserBase /oapi/user/modules 的 " - + "appCode 参数(H4)未上线,SDK 无对应入参,按缺口返回空树", appCode); - return Collections.emptyList(); - } - if (authUserWithDept == null) { - return Collections.emptyList(); - } - Integer schId = authUserWithDept.getCurrentRole() != null - ? authUserWithDept.getCurrentRole().getSchId() : null; - List modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schId); - if (modules == null || modules.isEmpty()) { - return Collections.emptyList(); - } - // 用户中心已按 BoaModule.children 下发模块树:本地不再重组层级,仅做前端字段改名映射 - return toFrontendTree(modules); - } - - /** - * BoaModule 树 → 前端 layout 期望的字段形状(递归,只改名不组树;Notice/svc-hall/Cms 同款口径)。
- * 层级与兄弟顺序均由用户中心决定(customId 升序),本侧不重排;叶子 children 为空列表。
- * 包级可见而非 private:供同包单测直接覆盖映射,避免为测试改写 {@code YschBaseOpenApi} 全局静态客户端。 - */ - static List> toFrontendTree(List modules) { - List> result = new ArrayList<>(); - if (modules == null || modules.isEmpty()) { - return result; - } - for (BoaModule m : modules) { - Map node = new LinkedHashMap<>(); - node.put("id", m.getId()); - node.put("modId", resolveModId(m)); - node.put("name", m.getName()); - node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); - node.put("page", m.getPath() != null ? m.getPath() : ""); - node.put("level", m.getLevel() != null ? m.getLevel() : 0); - node.put("icon", m.getIcon() != null ? m.getIcon() : ""); - node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); - node.put("usable", true); - node.put("children", toFrontendTree(m.getChildren())); - result.add(node); - } - return result; - } - - /** - * modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的 - * {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。 - * 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。 - * 中心应用的模块行自 T10(手册 §2.2 v1.5 五行版)起 customId 恒为数字 200~204;非数字时回退主键 id - * 只是防御性兜底,不再有"数据范围权限锚点"依赖这条映射(该协议已于 T10 退役,载体换成管理员两表)。 - */ - private static Object resolveModId(BoaModule m) { - String customId = m.getCustomId(); - if (customId != null && customId.trim().matches("\\d+")) { - return Integer.valueOf(customId.trim()); - } - return m.getId(); - } } 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 606ebaa..a810a55 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormMyAnswerController.java @@ -4,6 +4,7 @@ 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.formcenter.service.FormAccessPolicy; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; @@ -28,7 +29,10 @@ import java.util.List; *

  • 数据面身份只用 uuid:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);
  • *
  • 权限闸门:普通用户只能看/改/删自己提交的答卷(submitter_id=会话用户), * "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作); - * 有管理权者另有 /api/form/admin/data/**(T4 已落地,scope 矩阵生效),两套口径不混用;
  • + * 管理动作另有 {@code /api/form/admin/data/**}(按该表单 ALL 档把关,契约 v2 C4②),两套口径不混用; + *
  • 菜单自解析的三支路数据源都挂在本前缀下(§2.4/D5):{@code /fillable}(可填)、 + * {@code /managed}(我管理的)、{@code /capabilities}(校管理员能力位)—— + * UserBase 模块行整条退役后,前端不再从授权树里挑菜单,也不自行判权(只看这三把接口给什么);
  • *
  • 本控制器没有状态端点:填表人改状态在接口层即不存在(V14 的实现形态)。
  • * */ @@ -40,6 +44,7 @@ public class FormMyAnswerController { private final AnswerReadService readService; private final FillableFormService fillableService; + private final FormAccessPolicy accessPolicy; /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */ @GetMapping("/list") @@ -55,8 +60,8 @@ public class FormMyAnswerController { * *

    与 {@code /my/about} 是两个端点、两个谓词,不是一个端点加 view 参数 * (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。 - * 这里不看模块行、不看契约 §8 范围矩阵——数据管理面那套范围管的是"管理员能看谁的行", - * 本面管的是"我自己的行",两套口径混用会让填表人不授权模块行就看不见自己的数据。

    + * 这里不看角色、不看两档范围——数据管理面那套范围管的是"管理员能看谁的行", + * 本面管的是"我自己的行",两套口径混用会让填表人不进管理面就看不见自己的数据。

    */ @GetMapping("/submit-list") public List submitList(BoaUser user) { @@ -116,6 +121,35 @@ public class FormMyAnswerController { return fillableService.listFillable(user, appCode); } + /** + * 「可填写」清单的管理面同族端点(契约 v2 C3,{@code GET /api/form/my/managed}): + * 创建人 ∪ form_admin 命中的行,行形状=fillable 行 + {@code role} 来源标记。 + * + *

    它是 D5"菜单自解析"的第二支路数据源。与 {@code /fillable} 一样不接受外部传身份, + * 归属判定全在服务侧按会话算({@link FillableFormService#listManaged})。

    + */ + @GetMapping("/managed") + public List managed(BoaUser user, + @RequestParam(value = "appCode", required = false) String appCode) { + requireSession(user); + return fillableService.listManaged(user, appCode); + } + + /** + * 能力位(契约 v2 §2.4 第三支路):前端"系统设置"入口的唯一数据源。 + * + *

    只有 {@code schoolAdmin} 一个布尔——超管恒 true(§2.1)。它不是权限: + * 管理员两表、快应用注册、{@code GetSchoolAll} 各自在接口层按 C4③ 的角色门把关, + * 这里只回答"要不要给这个人画这个入口"。

    + */ + @GetMapping("/capabilities") + public MyModels.Capabilities capabilities(BoaUser user) { + requireSession(user); + MyModels.Capabilities caps = new MyModels.Capabilities(); + caps.setSchoolAdmin(accessPolicy.isSchoolAdmin(user)); + return caps; + } + 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/entity/FormAdmin.java b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java index 6a95292..40dbcd6 100644 --- a/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java +++ b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java @@ -11,7 +11,7 @@ import javax.persistence.UniqueConstraint; /** * 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的数据查看 + 导出授权(D3 裁决, - * 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。 + * 编辑权=该表 ALL 档下的管理动作(契约 v2 C4②),本表不扩定义权)。 * *

    {@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与 * 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为 diff --git a/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java index c484bfe..9fab453 100644 --- a/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java +++ b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java @@ -13,7 +13,8 @@ import javax.persistence.UniqueConstraint; * customId 权限锚点行(方案 §7)。 * *

    它只升级数据范围,不是页面入口凭证(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权 - * 模块行判({@code FormAdminGuard}),这张表只让 {@code DataScopeService} 把范围从"仅本人提交"升到 + * 角色门判({@code FormAdminGuard}+{@code FormAccessPolicy.isSchoolAdmin}),这张表只让 {@code DataScopeService} + * 把范围从"SELF"升到 * 本校表单=全部、基础表单=本校提交。

    * *

    {@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权 diff --git a/src/main/java/com/yunhai/formcenter/model/MyModels.java b/src/main/java/com/yunhai/formcenter/model/MyModels.java index 327361f..a0d7638 100644 --- a/src/main/java/com/yunhai/formcenter/model/MyModels.java +++ b/src/main/java/com/yunhai/formcenter/model/MyModels.java @@ -73,4 +73,42 @@ public final class MyModels { private Integer fillLimit; private String groupTag; } + + /** + * 一行"我管理的表单"(契约 v2 C3,{@code GET /api/form/my/managed}): + * 行形状=fillable 行 + {@code role} 来源标记数组,菜单自解析的"管理"支路数据源。 + * + *

    {@code role} 取值闭集 {@code creator}(C16 判定)/{@code form_admin}(两表命中), + * 两个来源同时命中时数组有两个元素——前端要能说出"你为什么管得动这张表"。

    + * + *

    与 fillable 的两处刻意差别:① 不做遮蔽去重(管理清单要"我能碰的每一张表", + * 同 code 的本校草稿与平台级表单是两条不同的可操作资产,藏掉一条就再也进不去); + * ② 不限生命周期(草稿/停用行同样要出现在管理支路,否则建了草稿的人找不到入口继续编辑)。

    + */ + @Getter + @Setter + public static class ManagedRow extends FillableRow { + /** + * 表单 uuid(P120 落地差额:C3 只写了"fillable 行 + role",但管理支路没有 uuid 就点不进 + * 设计器/数据页——数据面与定义面的寻址都是 uuid,code 只作入口)。 + */ + private String formUuid; + /** 生命周期(0 草稿/1 已发布/2 停用):管理支路要标出"这张还没发布" */ + private Integer status; + /** 创建人=超管/校管理员之外的人,靠这一行拿到继续编辑自己草稿的入口(D6 不设移交) */ + private List role; + } + + /** + * 能力位(契约 v2 §2.4 三支路自解析的第三支):前端"系统设置"入口的唯一数据源。 + * + *

    只有这一个布尔,是给前端画菜单用的——不是权限:接口侧每个端点各自按 C4 的角色门把关, + * 前端藏掉入口不等于没权限(契约 §7 身份行)。

    + */ + @Getter + @Setter + public static class Capabilities { + /** 校管理员(超管恒 true):管理员两表 / 快应用注册 / GetSchoolAll 的入口位 */ + private boolean schoolAdmin; + } } diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java index 27ae189..c244b01 100644 --- a/src/main/java/com/yunhai/formcenter/model/ReadModels.java +++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java @@ -11,7 +11,7 @@ import java.util.List; import java.util.Set; /** - * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。 + * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 v2 §2.3 两档 + §9,方案 §5.5)。 * *

    三条对外纪律:

    *
      diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java index ae46372..6fda5dd 100644 --- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java @@ -25,6 +25,13 @@ public interface AnswerRepository extends JpaRepository { Optional findByUuidAndSchId(String uuid, Long schId); + /** + * 按 (域, uuid 集合) 批量定位(T14 守卫:数据面入口要按答卷反查它所属的表单才能判 ALL 档)。 + * 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。 + */ + @Query("select a from Answer a where a.schId = :schId and a.uuid in (:uuids)") + List findBySchIdAndUuidIn(@Param("schId") Long schId, @Param("uuids") Collection uuids); + /** * 两段式列表的第二段(方案 §5.5 铁律①):按当页 id 批量取整文档。 * 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json}; diff --git a/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java index edaadc8..00e53f6 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java @@ -19,4 +19,10 @@ public interface FormAdminRepository extends JpaRepository { Optional findByIdAndFormUuid(Long id, String formUuid); boolean existsByFormUuidAndUserId(String formUuid, Integer userId); + + /** + * 按人反查"我被授权了哪些表单"(T14 的 managed 清单,C3)。 + * {@code idx_form_admin_user} 就是为这条留的(方案 §8 登记的 v2 入口,本批兑现)。 + */ + List findByUserId(Integer userId); } diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java index 7175b98..7fb6e57 100644 --- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java @@ -53,4 +53,17 @@ public interface FormRepository extends JpaRepository { */ @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); + + /** + * 「我管理的」清单取域(契约 v2 C3):域内未软删的表单,不限生命周期—— + * 草稿与停用行都要在管理支路里出现(创建人要能继续改自己没发布的表)。 + * 归属判定(创建人 / form_admin)在服务侧做,理由同 {@link #findLiveBySch}: + * 可空参数与 OR 型 JPQL 在 MySQL 上有类型推断坑(P10),且一校的表单量级是个位数到几百。 + */ + @Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 order by f.updateTime desc, f.id desc") + List findLiveInDomain(@Param("schIds") Collection schIds); + + /** 按 uuid 批量取头表(T14 守卫:由答卷反查所属表单以判 ALL 档;uk_form_uuid 唯一) */ + @Query("select f from Form f where f.uuid in (:uuids)") + List findByUuidIn(@Param("uuids") Collection uuids); } diff --git a/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java b/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java index 002bbc3..846755d 100644 --- a/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java +++ b/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java @@ -4,9 +4,9 @@ package com.yunhai.formcenter.service; * 管理员两表的只读判定端口(T10 方案 §5.1):范围解析与守卫都从这里问"这个人在不在 * {@code school_admin} / {@code form_admin} 里",生产实现是 {@code AdminDomainService}(直查本库两表)。 * - *

      为什么做成端口:{@code DataScopeService} 与 {@code FormAdminGuard} 都只消费这两个布尔判定, + *

      为什么做成端口:{@code DataScopeService} 与 {@code FormAccessPolicy} 都只消费这两个布尔判定, * 加宽一侧(比如将来按人反查"我管理的表单",方案 §8 的 v2 入口)不该把仓库与写侧逻辑渗进解析器; - * 同 {@link ModuleTreeSource}/{@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。

      + * 同 {@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。

      * *

      口径:判定只认会话身份({@code (schId, userId)} / {@code (formUuid, userId)}), * 查不到即 false(fail-closed)——残留行(教师离校)因会话失效而永远走不到这里,方案 §8。

      diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java index d1dca82..02d69d0 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java @@ -44,10 +44,12 @@ import java.util.List; * 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。 *
    * - *

    两层权限都过(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard}, - * 决定"能不能用数据管理接口"),每条答卷再过一次行级范围——判据是四条读出路径共用的 + *

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

    + * 编辑/软删/改状态也做不了:ALL 档入口进来之后,行级仍按同一份范围规格判——"契约 v2 §2.3 + * 的租户限定变体"下,本校之外的行(含 {@code submitter_sch_id=NULL} 的存量行)照改不动。

    */ @Slf4j @Service @@ -210,7 +212,7 @@ public class AnswerAdminService { new ReadModels.QueryRequest()); if (!queryBuilder.visible(scope, answer.getUuid())) { throw new BizException(ErrorCode.FORBIDDEN, - "该答卷不在你的数据范围内(契约 §8 矩阵),不能执行管理动作:" + answerUuid); + "该答卷不在你的数据范围内(契约 v2 §2.3 两档),不能执行管理动作:" + answerUuid); } return answer; } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java index f171ba6..0e34217 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -33,7 +33,7 @@ import java.util.List; import java.util.Map; /** - * 数据面读服务(列表 / 详情 / 筛选候选,契约 §8/§9,方案 §5.5)。 + * 数据面读服务(列表 / 详情 / 筛选候选,契约 v2 §2.3 两档范围 + §6/§9 契约,方案 §5.5)。 * *

    与 {@link AnswerScopeQueryBuilder} 的分工:本类负责"把请求翻译成查询规格" * (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。 @@ -46,8 +46,8 @@ import java.util.Map; * 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。

    * *

    末尾的"我的填写"段是另一条线,不要与上面四条混读:那里的可见集来自 - * {@code submitter_id}/{@code subject_id}=会话用户的归属判定,不是契约 §8 的范围矩阵 - * (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点"); + * {@code submitter_id}/{@code subject_id}=会话用户的归属判定,不是 §2.3 的两档范围 + * (填表人不进任何管理面也能看自己的数据,方案 §6.3"我的填写两层落点"); * 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。

    */ @Slf4j @@ -150,7 +150,7 @@ public class AnswerReadService { Form form = pinned.form(); ReadModels.Query query = scopeQuery(user, domainSchId, form, new ReadModels.QueryRequest()); if (!queryBuilder.visible(query, answer.getUuid())) { - throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 §8 矩阵):" + answerUuid); + throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 v2 §2.3 两档):" + answerUuid); } ReadModels.DetailView view = new ReadModels.DetailView(); copyInto(answer, form, view); @@ -380,9 +380,9 @@ public class AnswerReadService { * {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的), * {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。 * - *

    与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有 - * {@code /admin/data} 模块行;这里不看模块行、不看范围矩阵,只看"这条数据是不是我的", - * 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。

    + *

    与数据管理面的区别:那条路径的范围来自 {@link DataScopeService} 的两档解析且入口按 ALL 档 + * 把关(契约 v2 C4②);这里不看角色、不看范围,只看"这条数据是不是我的", + * 所以填表人(学生)不进任何管理面也能用自己的两视图。

    */ public List listMine(BoaUser user, boolean bySubject) { Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属"); diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java index 5fdeb53..c86ec0c 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java @@ -29,10 +29,10 @@ import java.util.Map; import java.util.Set; /** - * 数据范围查询构造器(方案 §6.4 红线 / 契约 §8 红线的唯一实现处): + * 数据范围查询构造器(方案 §6.4 红线 / 契约 v2 §2.3 红线的唯一实现处): * 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关, * 全部调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、 - * 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。 + * 统计一调返回全校分布,两档范围就白定了),故本类不接受"跳过范围"的开关参数。 * *

    一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)} * → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询) diff --git a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java index 32f0d12..c855fee 100644 --- a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java +++ b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java @@ -97,9 +97,22 @@ public class DataScopeService { * 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase * 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。

    */ - private static boolean isCreator(BoaUser user, Form form) { + /** + * 创建人判定(C16):{@code creator_id}==会话 uid 或 {@code create_by}==会话账号名。 + * + *

    兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id + * 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase + * 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。

    + * + *

    公开给 {@link FormAdminGuard} 复用(C4①"定义域写含该表单创建人"与 ALL 档 + * 必须是同一条判定,两处各写一份就会漂移)。

    + */ + public static boolean isCreator(BoaUser user, Form form) { + if (user == null || user.getId() == null || form == null) { + return false; + } if (form.getCreatorId() != null) { - return user.getId() != null && form.getCreatorId().longValue() == user.getId().longValue(); + return form.getCreatorId().longValue() == user.getId().longValue(); } String account = FormAccessPolicy.sessionAccount(); return account != null && !account.trim().isEmpty() && account.trim().equals(form.getCreateBy()); diff --git a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java index af344d4..19a7df6 100644 --- a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java +++ b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java @@ -1,8 +1,10 @@ package com.yunhai.formcenter.service; import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormAdmin; import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.model.MyModels; +import com.yunhai.formcenter.repository.FormAdminRepository; import com.yunhai.formcenter.repository.FormRepository; import com.yunhai.platform.utils.model.BoaUser; import lombok.RequiredArgsConstructor; @@ -21,8 +23,9 @@ import java.util.Map; import java.util.Set; /** - * 「我可填」清单解析({@code GET /api/form/my/fillable},契约 v2 C2 行形状): - * 把"这个会话用户能进哪些表单的填报页"算成一张列表——数据源不再是 UserBase 模块树。 + * 「我可填」与「我管理的」两张清单({@code GET /api/form/my/fillable} 契约 v2 C2 行形状、 + * {@code GET /api/form/my/managed} C3):把"这个人能填哪些表、管哪些表"各算成一张列表—— + * 数据源不再是 UserBase 模块树,两支路都由中心自解析(契约 v2 §2.4/D5)。 * *

    四步,一步不多:

    *
      @@ -51,6 +54,7 @@ public class FillableFormService { private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final FormRepository formRepository; + private final FormAdminRepository formAdminRepository; /** * 我可填清单。 @@ -123,4 +127,59 @@ public class FillableFormService { row.setGroupTag(form.getGroupTag()); return row; } + + /** + * 「我管理的」清单(契约 v2 C3,菜单自解析的"管理"支路):创建人 ∪ form_admin 命中的行。 + * + *

      取域与「我可填」同为"会话校 ∪ 平台级",但不做遮蔽去重、不限生命周期 + * (理由见 {@link MyModels.ManagedRow} 的类注释)。排序沿仓库的 {@code update_time desc} + * ——管理动作关心"最近碰过哪张",不像填报清单那样有截止时间这个天然主序。

      + * + * @param appCode 可选;非空=只要该应用下的行 + */ + public List listManaged(BoaUser user, String appCode) { + Long schId = FormAccessPolicy.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); + + List administered = new ArrayList<>(); + for (FormAdmin grant : formAdminRepository.findByUserId(user.getId())) { + if (grant.getFormUuid() != null) { + administered.add(grant.getFormUuid()); + } + } + + List rows = new ArrayList<>(); + for (Form form : formRepository.findLiveInDomain(domain)) { + if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) { + continue; + } + List roles = new ArrayList<>(); + if (DataScopeService.isCreator(user, form)) { + roles.add("creator"); + } + if (form.getUuid() != null && administered.contains(form.getUuid())) { + roles.add("form_admin"); + } + if (roles.isEmpty()) { + continue; + } + MyModels.ManagedRow row = new MyModels.ManagedRow(); + row.setFormUuid(form.getUuid()); + row.setStatus(form.getStatus()); + MyModels.FillableRow base = toRow(form); + row.setCode(base.getCode()); + row.setName(base.getName()); + row.setAppCode(base.getAppCode()); + row.setAudience(base.getAudience()); + row.setDeadline(base.getDeadline()); + row.setFillLimit(base.getFillLimit()); + row.setGroupTag(base.getGroupTag()); + row.setRole(roles); + rows.add(row); + } + return rows; + } } diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java index 8921c6d..2203e17 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java +++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java @@ -1,87 +1,240 @@ package com.yunhai.formcenter.service; -import com.yunhai.formcenter.common.FormModuleKeys; +import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.repository.AnswerRepository; +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 org.springframework.stereotype.Service; -import java.util.Set; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** - * 管理面守卫(方案 §6.1 第②层 / §6.5 / 执行手册 §2.2)。 + * 管理面守卫(契约 v2 C4):模块行退役后,这里只剩角色门 + 租户域推导两件事。 * - *

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

      - *
        - *
      • 定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats}, - * 应用设置={@code /admin/app}(T10 方案 §3:字典管理行撤销、迁入应用设置页内 tab, - * 字典 API 摊这一行把关;手册 §2.2 表 v1.5 起是五行版)。
      • - *
      • 超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、 - * 且是唯一能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定, - * 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。
      • - *
      + *

      三段判定逐字对齐 C4(《契约v2》§0.4):

      + *
        + *
      1. 定义域——新建/另存副本=超管 ∪ 校管理员(G1 用户裁决定稿的"创建权"); + * 对某张已存在表单的写(save/publish/disable/enable/softDelete/config/statusDefs) + * 与读(list/get/versions/version/filterFields)=超管 ∪ 校管理员 ∪ 该表单创建人 + * (创建人保留定义权,D6 不设移交);
      2. + *
      3. 数据域——{@code /admin/data/**} 读写=该表单 ALL 档持有者; + * 行级仍由 {@link AnswerScopeQueryBuilder} 把关(V55 唯一出口),本守卫只回答"能不能进这个面"; + * 原 {@code canManage}(模块行判"有没有代填权")并入同一条 ALL 档判定,由 + * {@code AnswerService#requireSubject} 消费;
      4. + *
      5. 平台管理域——管理员两表、app 注册(C17)、{@code GetSchoolAll}=校管理员 ∪ 超管。
      6. + *
      * - *

      与管理权正交的另一条线:数据面能看哪些行由契约 §8 的角色矩阵 + 管理员两表决定 - * ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行, - * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。 - * 管理员两表不充当页面入口凭证(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里 - * 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。

      + *

      与 T4~T10 的口径差别:原来"能不能进页面"的事实源在 UserBase 授权模块树 + * ({@code ModuleTreeSource}),现在在本库的角色与所有权事实里——两条树不复存在, + * 也就不再有"菜单看得见、接口调不动"的两套口径(§19 作废-12)。 + * 租户域推导规则一条没动:超管可显式指定目标域(含 0=平台级),校级只能本校且传 0 直接拒。

      + * + *

      fail-closed:未登录拒;无学校归属拒;表单取不到与"不是你域内的表单"同口径回答 + * (uuid 不可枚举,存在性本身就是探测面)。

      */ @Service @RequiredArgsConstructor public class FormAdminGuard { - /** 定义管理模块行 path(手册 §2.2:表单设计) */ - public static final String MODULE_FORM_DESIGN = "/admin/forms"; - /** 数据管理模块行 path */ - public static final String MODULE_DATA_MANAGE = "/admin/data"; - /** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */ - public static final String MODULE_STATS = "/admin/stats"; - /** - * 应用设置模块行 path(T10 方案 §3,取代原字典管理行 {@code /admin/dicts}): - * 页内两个 tab(角色设置/字典管理)共用这一行,字典 API 也摊它把关。 - */ - public static final String MODULE_APP_SETTINGS = "/admin/app"; - - private final ModuleTreeSource moduleTreeSource; + private final FormAccessPolicy accessPolicy; + private final DataScopeService dataScope; + private final AnswerRepository answerRepository; + private final FormRepository formRepository; /** - * 校验"被授权了 {@code modulePath} 这个模块行"并返回本次操作的租户域。 + * 定义域·新建/另存副本(还没有表单对象可判):超管 ∪ 校管理员(G1)。 * - * @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行) - * @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒 - * @param modulePath 需要的模块行 path(本类的四个 {@code MODULE_*} 常量之一) + * @param targetSchId 目标域;超管可显式指定(含 0=平台级),校级传入不等本校即拒 */ - public Long requireAdmin(BoaUser user, Long targetSchId, String modulePath) { - if (user == null || user.getId() == null) { - throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); - } + public Long requireCreate(BoaUser user, Long targetSchId) { + requireSession(user); if (FormAccessPolicy.isSuperAdmin(user)) { - if (targetSchId != null) { - return targetSchId; + return superAdminDomain(user, targetSchId); + } + if (!accessPolicy.isSchoolAdmin(user)) { + throw new BizException(ErrorCode.FORBIDDEN, + "无新建表单权:新建/另存副本要求超管或校管理员(当前身份 " + describe(user) + ")"); + } + return schoolDomain(user, targetSchId); + } + + /** + * 定义域·针对某张表单:超管 ∪ 校管理员 ∪ 该表单创建人(C4①/G1)。 + * + * @param form 目标表单(由控制器经 {@code FormDefService#findFormForGuard} 取来); + * null=按"还没有表单"的形态处理(同 {@link #requireCreate}) + */ + public Long requireDefinition(BoaUser user, Long targetSchId, Form form) { + if (form == null) { + return requireCreate(user, targetSchId); + } + requireSession(user); + if (FormAccessPolicy.isSuperAdmin(user)) { + return superAdminDomain(user, targetSchId); + } + boolean holder = accessPolicy.isSchoolAdmin(user) || dataScope.isCreator(user, form); + if (!holder) { + throw new BizException(ErrorCode.FORBIDDEN, "无该表单定义权:需要超管、校管理员或该表单创建人(" + + "表单 " + form.getAppCode() + "/" + form.getCode() + ",当前身份 " + describe(user) + ")"); + } + Long domain = schoolDomain(user, targetSchId); + long formSch = form.getSchId(); + if (formSch != domain.longValue() && formSch != Form.PLATFORM_SCH_ID) { + // 他校表单:与"表单不存在"同口径(存在性差别就是探测面,契约 §1) + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid()); + } + return domain; + } + + /** + * 数据域({@code /admin/data/**} 读写):该表单 ALL 档持有者(C4②)。 + * + *

      校管理员在平台级表单上拿到的是 ALL 档的租户限定变体(D9),照样算 ALL 档持有者—— + * 进得来,但行集由 {@link AnswerScopeQueryBuilder} 按同一份规格收窄。

      + */ + public Long requireData(BoaUser user, Long targetSchId, Form form) { + requireSession(user); + if (FormAccessPolicy.isSuperAdmin(user)) { + return superAdminDomain(user, targetSchId); + } + if (form == null || form.getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在"); + } + Long domain = schoolDomain(user, targetSchId); + long formSch = form.getSchId(); + if (formSch != domain.longValue() && formSch != Form.PLATFORM_SCH_ID) { + throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid()); + } + if (!dataScope.resolve(user, form).all()) { + throw new BizException(ErrorCode.FORBIDDEN, "无该表单数据权:需要 ALL 档" + + "(创建人 ∪ 表单管理员 ∪ 校管理员;表单 " + form.getAppCode() + "/" + form.getCode() + ")"); + } + return domain; + } + + /** 平台管理域(管理员两表 / app 注册 / GetSchoolAll / 字典):校管理员 ∪ 超管(C4③) */ + public Long requirePlatformAdmin(BoaUser user, Long targetSchId) { + requireSession(user); + if (FormAccessPolicy.isSuperAdmin(user)) { + return superAdminDomain(user, targetSchId); + } + if (!accessPolicy.isSchoolAdmin(user)) { + throw new BizException(ErrorCode.FORBIDDEN, + "无平台管理权:需要校管理员或超管(当前身份 " + describe(user) + ")"); + } + return schoolDomain(user, targetSchId); + } + + // ==================== 按答卷寻址的数据面入口(答卷里没有表单坐标) ==================== + + /** + * 数据面入口·单条答卷:先由答卷反查它所属的表单,再按 {@link #requireData} 判 ALL 档。 + * + *

      原形态是"入口按模块行取域、行级由 scope 构造器判";模块行退役后入口判据也是 ALL 档, + * 所以必须先把表单找出来——{@code /admin/data/{edit,softDelete,changeStatus}} 与 {@code detail} + * 的请求体里只有 {@code answerId}。

      + */ + public Long requireDataByAnswer(BoaUser user, Long targetSchId, String answerUuid) { + Long domain = dataDomain(user, targetSchId); + Map byUuid = formsOfAnswers(domain, Collections.singletonList(answerUuid)); + Form form = answerUuid == null ? null : byUuid.get(answerUuid.trim()); + return requireData(user, domain, form); + } + + /** + * 数据面入口·批量:批量里的答卷可能跨表单,所以逐张表都要判 ALL 档, + * 任意一张不在档内就整把请求拒(不放进服务里再逐条拒——那样 {@code V14}"填表人不能改状态" + * 会从批量通道漏出去)。 + */ + public Long requireDataByAnswers(BoaUser user, Long targetSchId, List answerUuids) { + Long domain = dataDomain(user, targetSchId); + if (FormAccessPolicy.isSuperAdmin(user)) { + return domain; + } + requireSession(user); + Map forms = formsOfAnswers(domain, answerUuids); + for (String answerUuid : answerUuids) { + Form form = answerUuid == null ? null : forms.get(answerUuid.trim()); + if (form == null) { + // 答卷找不到=按"答卷不存在"回答(与读路径同口径),这里交给服务层的 requireLiveAnswer + continue; } - Long current = FormAccessPolicy.sessionSchId(user); - if (current != null) { - return current; + if (!dataScope.resolve(user, form).all()) { + throw new BizException(ErrorCode.FORBIDDEN, "无该表单数据权:批量里每张表都要求 ALL 档" + + "(创建人 ∪ 表单管理员 ∪ 校管理员;卡在 " + form.getAppCode() + "/" + form.getCode() + ")"); } - throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)"); + } + return domain; + } + + /** 数据面的域推导(不改 requireData 的判定,只是答卷寻址入口要先拿到域才能查答卷) */ + private Long dataDomain(BoaUser user, Long targetSchId) { + requireSession(user); + if (FormAccessPolicy.isSuperAdmin(user)) { + return superAdminDomain(user, targetSchId); + } + return schoolDomain(user, targetSchId); + } + + /** 两把查询拿回"这批答卷各自属于哪张表"(键=answer uuid,值=头表;查不到的键不出现在结果里) */ + private Map formsOfAnswers(Long domain, List answerUuids) { + List clean = new ArrayList<>(); + for (String uuid : answerUuids) { + if (uuid != null && !uuid.trim().isEmpty()) { + clean.add(uuid.trim()); + } + } + Map result = new HashMap<>(); + if (clean.isEmpty()) { + return result; + } + List answers = answerRepository.findBySchIdAndUuidIn(domain, clean); + List formUuids = new ArrayList<>(); + for (Answer answer : answers) { + if (answer.getFormUuid() != null && !formUuids.contains(answer.getFormUuid())) { + formUuids.add(answer.getFormUuid()); + } + } + Map byFormUuid = new HashMap<>(); + for (Form form : formRepository.findByUuidIn(formUuids)) { + byFormUuid.put(form.getUuid(), form); + } + for (Answer answer : answers) { + result.put(answer.getUuid(), byFormUuid.get(answer.getFormUuid())); + } + return result; + } + + // ==================== 租户域推导(T4 起口径原样承继) ==================== + + /** 超管:显式目标域优先(含 0=平台级),否则落会话校,都给不出就要求显式指定 */ + private static Long superAdminDomain(BoaUser user, Long targetSchId) { + if (targetSchId != null) { + return targetSchId; } Long current = FormAccessPolicy.sessionSchId(user); - if (!hasModule(user, modulePath)) { - throw new BizException(ErrorCode.FORBIDDEN, "无该模块权限:" + modulePath); + if (current != null) { + return current; } + throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)"); + } + + /** 校级:会话校唯一合法域;平台级行与别校域一律拒 */ + private static Long schoolDomain(BoaUser user, Long targetSchId) { + Long current = FormAccessPolicy.sessionSchId(user); if (current == null) { - // 授权树是按 (userId, schId 上下文) 取的,无学校归属时返回的树不可信; - // 更关键的是这里必须返回一个"本校"域,给不出就不能放行 + // 角色判定可能来自天然校领导(无学校归属的身份给不出"本校"域),这里必须拒而不是放行 throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定管理域"); } if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) { - // 平台级行=跨校资产:校级管理员把目标域传成 0,先按"平台级不归学校管"讲清楚, - // 而不是笼统说一句"只能管本校"(同一条拒绝,原因要说准) throw new BizException(ErrorCode.FORBIDDEN, "平台级表单/字典仅超管可维护"); } if (targetSchId != null && !targetSchId.equals(current)) { @@ -90,102 +243,14 @@ public class FormAdminGuard { return current; } - /** - * 是否具备管理权(超管,或被授权定义管理/数据管理任一模块行)。不含租户域核对—— - * 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。 - * - *

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

      - */ - public boolean canManage(BoaUser user) { - if (user == null || user.getId() == null) { - return false; - } - if (FormAccessPolicy.isSuperAdmin(user)) { - return true; - } - if (FormAccessPolicy.sessionSchId(user) == null) { - return false; - } - Set paths = authorizedPaths(user); - return paths.contains(MODULE_DATA_MANAGE) || paths.contains(MODULE_FORM_DESIGN); - } - - /** - * 同一条校验的"任一模块行命中即放行"形态。 - * - *

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

      - */ - public Long requireAdminAny(BoaUser user, Long targetSchId, String... modulePaths) { + private static void requireSession(BoaUser user) { if (user == null || user.getId() == null) { throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); } - if (modulePaths == null || modulePaths.length == 0) { - throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模块行清单为空(调用方写错了)"); - } - if (FormAccessPolicy.isSuperAdmin(user)) { - return requireAdmin(user, targetSchId, modulePaths[0]); - } - Set held = authorizedPaths(user); - for (String modulePath : modulePaths) { - if (held.contains(modulePath)) { - // 命中即按该模块行走完整租户域校验(复用同一段拒绝逻辑,不在这里另写一份域判定) - return requireAdmin(user, targetSchId, modulePath); - } - } - throw new BizException(ErrorCode.FORBIDDEN, - "无该模块权限(需任一):" + String.join(" 或 ", modulePaths)); } - /** - * 数据面读出路径的入口判定(T10 方案 §5.2):列表 / 筛选候选 / 统计 / 导出四条按表单寻址的 - * 读接口用它,返回本次查询的租户域。与 {@link #requireAdmin} 的差别只有两处: - *
        - *
      1. 数据管理或统计任一模块行即可(手册 §2.2 的"统计可并入数据管理,二选一"推广到整个 - * 数据面——只授统计行的人不该在列表页拿到 403 而菜单里又确实有数据面);
      2. - *
      3. 按表单归属域判,于是基础表单({@code sch_id=0})在本校域下可读(T4 起就是这个口径, - * 本方法不收窄它),而他校表单与"表单不存在"同口径回答(uuid 不可枚举,契约 §1)。
      4. - *
      - * - *

      进来之后能看哪些行仍然只由 {@link DataScopeService} 决定:管理员两表在这里不作入口凭证 - * (T10 任务书 §0-4 守卫红线),它们只把范围从"仅本人提交"升到"全部/本校提交"。

      - * - *

      详情面(按 answerId 寻址)入口时还没有表单对象,沿用 {@link #requireAdminAny} 取域 + - * 构造器 {@code visible()} 的行级把关——同一份范围规格,可见集与列表必然同集(V27 的断言形态)。

      - */ - public Long requireDataView(BoaUser user, Long targetSchId, Form form) { - if (user == null || user.getId() == null) { - throw new BizException(ErrorCode.UNAUTHORIZED, "未登录"); - } - if (FormAccessPolicy.isSuperAdmin(user)) { - // 超管的域语义原样交给 requireAdmin:显式目标域(含 0=平台级)优先,不在这里换默认值 - return requireAdmin(user, targetSchId, MODULE_DATA_MANAGE); - } - if (form == null || form.getSchId() == null) { - // 取不到表单与"不是你域内的表单"同一口径:存在性本身就是探测面(契约 §1 uuid 不可枚举) - throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在"); - } - Long current = FormAccessPolicy.sessionSchId(user); - if (current == null) { - throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定查询域"); - } - long formSch = form.getSchId(); - if (formSch != current && formSch != Form.PLATFORM_SCH_ID) { - // 他校表单:按"不存在"回答(同 FormDefService#readDomainForm 的拒绝口径) - throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid()); - } - return requireAdminAny(user, targetSchId, MODULE_STATS, MODULE_DATA_MANAGE); - } - - /** 会话用户被授权的模块行 path 集合(数据范围解析与调试取证用) */ - public Set authorizedPaths(BoaUser user) { - return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user)); - } - - /** 是否被授权某个模块行(path 精确匹配;超管直接放行) */ - private boolean hasModule(BoaUser user, String modulePath) { - return FormAccessPolicy.isSuperAdmin(user) || authorizedPaths(user).contains(modulePath); + /** 拒绝文案里的身份要素(可诊断:是角色不够还是身份没带对) */ + private static String describe(BoaUser user) { + return "id=" + user.getId() + " type=" + user.getType() + " roleIds=" + user.getRoleIds(); } } diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 42ed83a..140df4a 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -274,7 +274,7 @@ public class FormDefService { /** * 数据面读接口入口把关用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定, - * 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}—— + * 取不到返回 null。域与角色的判断全在 {@code FormAdminGuard#requireData}—— * 那里才知道"他校表单与不存在的表单要用同一口径回答",本方法刻意不给拒绝话术。 */ public Form findFormForGuard(String formUuid) { diff --git a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java deleted file mode 100644 index a794488..0000000 --- a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.yunhai.formcenter.service; - -import com.yunhai.platform.utils.model.BoaModule; -import com.yunhai.platform.utils.model.BoaUser; - -import java.util.List; - -/** - * 会话用户在本应用下的已授权模块树来源(方案 §6.1 第②层,权限模型的唯一权威载体)。 - * - *

      本树回答管理权一类问题(执行手册 §5 P21/P30 裁决落地口径):定义管理/数据管理/统计/ - * 应用设置=被授权对应模块行(path 命中,手册 §2.2 表 v1.5 五行版)。

      - * - *

      T10 起数据范围不再从这棵树出:{@code customId} 权限锚点协议退役(管理员体系方案 §7), - * 范围升级改由本库的管理员两表提供({@link AdminScopeSource})。path 空的锚点形态本身仍存在于 - * 快应用面(导入/导出按钮,前端 {@code modulePerms} 消费,T9/P76 产物),只是中心后端不再扫描它。

      - * - *

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

      - * - *

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

      - */ -public interface ModuleTreeSource { - - /** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */ - List authorizedModules(BoaUser user); -} diff --git a/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java deleted file mode 100644 index dddf885..0000000 --- a/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.yunhai.formcenter.service; - -import com.yunhai.platform.openapi.base.YschBaseOpenApi; -import com.yunhai.platform.utils.model.BoaModule; -import com.yunhai.platform.utils.model.BoaUser; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.Collections; -import java.util.List; - -/** - * {@link ModuleTreeSource} 的生产实现:平台标准签名调用 UserBase {@code /oapi/user/modules} - * (经 SDK {@code YschBaseOpenApi.getUserModule(userId, schId)},与前端壳 GetUserMod 同一条链路、 - * 同一个数据源——管理权与导航菜单因此不会有两套口径,方案 §6.1 运行时链路)。 - * - *

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

      - */ -@Slf4j -@Service -public class PlatformModuleTreeSource implements ModuleTreeSource { - - @Override - public List authorizedModules(BoaUser user) { - if (user == null || user.getId() == null) { - return Collections.emptyList(); - } - Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); - try { - List modules = YschBaseOpenApi.getUserModule(user.getId(), schId); - return modules == null ? Collections.emptyList() : modules; - } catch (Exception e) { - // fail-closed:取不到树就当没有授权——管理权拒、数据范围回落到仅本人提交(接口注释) - log.warn("[模块树] UserBase /oapi/user/modules 不可达,按无授权处理 userId={} schId={}:{}", - user.getId(), schId, e.getMessage()); - return Collections.emptyList(); - } - } -} diff --git a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java index 9fd544d..97b7524 100644 --- a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java +++ b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java @@ -1,123 +1,58 @@ package com.yunhai.formcenter.controller.api; -import com.yunhai.formcenter.service.FormAdminGuard; -import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; /** - * GetUserMod 映射与 target appCode 判定单测(本地 {@code mvn test} 即门禁,CI 不跑单测)。
      - * 映射部分锁定三个易错点:modId 取数字 custom_id、children 恒为数组(前端无判空递归)、 - * path 缺失归空串。appCode 部分锁定「指向别的应用时绝不打 SDK」—— - * 单测里 SDK 静态客户端未装配,误走本应用分支会直接抛异常而不是返回空树。 + * 前端壳基础端点单测(契约 v2 §2.4 / 验收 V72)。 + * + *

      原 {@code GetUserMod} 的 BoaModule 树映射与"外指 appCode 空树不回落"(P3 裁决)两批用例 + * 随端点退役整体删除——模块行整条退役后中心不再消费授权树,那套映射没有调用方了 + * (§19 作废-10;B14 缺口与跨项目触点 H4 一并消失)。

      + * + *

      留两条断言:① {@code GetUserInfo} 仍原样回话会话用户(App.vue 引导的唯一请求); + * ② 反射级"端点真拆"断言——{@code /api/base/GetUserMod} 这个映射在控制器上不存在。 + * 拆面必须真拆(任务书 §1 红线 10):留着空实现或只删调用方都算双轨。

      */ +@DisplayName("FormBaseController - 壳基础端点与 GetUserMod 退役(V72)") class FormBaseControllerTest { @Test - void toFrontendTree_shouldKeepServerHierarchyAndRenameFields() { - BoaModule parent = module(501, "100", "/admin/forms", 1); - BoaModule child = module(502, "101", "/admin/data", 2); - parent.setChildren(new ArrayList<>(Collections.singletonList(child))); - BoaModule otherRoot = module(503, "105", "/my", 1); - - List> tree = FormBaseController.toFrontendTree(Arrays.asList(parent, otherRoot)); - - assertEquals(2, tree.size(), "顶层只应有两个根,子节点不得浮到顶层"); - Map first = tree.get(0); - assertEquals("/admin/forms", first.get("page"), "path → page(前端按 page 匹配菜单高亮)"); - assertEquals(100, first.get("modId"), "modId 必须取数字 custom_id 而非主键 id"); - assertEquals(501, first.get("id"), "原始主键仍经 id 字段透出"); - - @SuppressWarnings("unchecked") - List> children = (List>) first.get("children"); - assertEquals(1, children.size(), "层级应原样保留"); - assertEquals(101, children.get(0).get("modId")); - } - - @Test - void toFrontendTree_shouldAlwaysEmitChildrenAsArray() { - BoaModule configuredLeaf = module(601, "102", "/admin/app", 1); - configuredLeaf.setChildren(new ArrayList<>()); - BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null - - List> tree = FormBaseController.toFrontendTree(Arrays.asList(configuredLeaf, notExpanded)); - - for (Map node : tree) { - assertNotNull(node.get("children"), "children 不得为 null,前端要无判空递归"); - assertTrue(((List) node.get("children")).isEmpty(), "无子节点时应为空列表"); - } - } - - @Test - void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() { - // 手册 §2.2 v1.5 起中心五行的 customId 恒为数字(200~204),非数字只是上游配错时的兜底: - // 映射不炸、modId 退回主键、path 空行给空串(前端导航按 path 空跳过,快应用按钮锚点同形态) - BoaModule misconfigured = module(701, "not-a-number", null, 1); - - List> tree = FormBaseController.toFrontendTree(Collections.singletonList(misconfigured)); - - assertEquals(701, tree.get(0).get("modId"), "custom_id 非数字时回退模块主键 id"); - assertEquals("", tree.get(0).get("page"), "path 缺失应给空串而非 null(前端字符串比较不判空)"); - } - - @Test - void toFrontendTree_shouldTolerateEmptyAndNullInput() { - assertTrue(FormBaseController.toFrontendTree(Collections.emptyList()).isEmpty()); - assertTrue(FormBaseController.toFrontendTree(null).isEmpty(), "上游返回 null 不得抛 NPE"); - } - - @Test - void isForeignTarget_shouldOnlyTreatOtherAppCodesAsForeign() { - assertFalse(FormBaseController.isForeignTarget(null), "缺省=本应用"); - assertFalse(FormBaseController.isForeignTarget(" "), "空白=缺省"); - assertFalse(FormBaseController.isForeignTarget("form-center"), "显式等于本应用仍走本应用分支"); - assertFalse(FormBaseController.isForeignTarget(" Form-Center "), "比对忽略大小写与首尾空白"); - assertTrue(FormBaseController.isForeignTarget("honor"), "快应用 app_code 才是目标应用分支"); - } - - @Test - void getUserMod_shouldReturnEmptyTreeForForeignTargetWithoutTouchingSdk() { + void getUserInfo_shouldReturnSessionUserAsIs() { BoaUser user = new BoaUser(); user.setId(10133); + user.setName("李老师"); - List> tree = controller().getUserMod(user, "honor"); + BoaUser back = new FormBaseController(null, null).getUserInfo(user); - assertTrue(tree.isEmpty(), "H4(UserBase appCode 参数)未上线,目标应用一律空树"); + assertSame(user, back, "会话用户原样下发,不在这里裁剪字段(前端只取 id/name/avatar)"); + assertEquals(Integer.valueOf(10133), back.getId()); } @Test - void getUserMod_shouldReturnEmptyTreeWithoutSessionUser() { - assertTrue(controller().getUserMod(null, null).isEmpty()); - } - - /** - * 只覆盖静态映射与外指分支的控制器实例:这两条路径都不碰注入的协作者, - * 组织树服务给 null 即可(真树在 SchoolUserTreeServiceTest 与真会话轮取证)。 - */ - private static FormBaseController controller() { - return new FormBaseController( - new FormAdminGuard(session -> Collections.emptyList()), null); - } - - private static BoaModule module(Integer id, String customId, String path, Integer level) { - BoaModule m = new BoaModule(); - m.setId(id); - m.setCustomId(customId); - m.setName("模块" + customId); - m.setPath(path); - m.setLevel(level); - m.setSortNo(level * 10); - return m; + void getUserModMappingShouldNoLongerExist() { + long gets = 0; + for (Method method : FormBaseController.class.getDeclaredMethods()) { + GetMapping get = method.getAnnotation(GetMapping.class); + if (get == null || method.isSynthetic()) { + continue; + } + gets++; + for (String path : get.value()) { + if (path.toLowerCase().contains("getusermod")) { + throw new AssertionError("GetUserMod 端点仍在(模块行未真拆):" + path); + } + } + } + // 只留两个端点:GetUserInfo 与 GetSchoolAll + assertEquals(2, gets, "壳基础端点应当只剩 GetUserInfo 与 GetSchoolAll"); } } diff --git a/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java b/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java index 68ed474..4b3b498 100644 --- a/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java @@ -3,6 +3,7 @@ package com.yunhai.formcenter.service; import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.model.AdminModels; 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; @@ -30,7 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest @Import(AnswerTestSupport.TestGatewayConfiguration.class) @Transactional -@DisplayName("管理员两表管理面 - 幂等/防呆/跨校(V45)与应用设置把关(V46)") +@DisplayName("管理员两表管理面 - 幂等/防呆/跨校(V45)与平台管理域把关(V72)") class AdminDomainIT extends AnswerTestSupport { @Autowired @@ -163,31 +164,32 @@ class AdminDomainIT extends AnswerTestSupport { .isInstanceOf(BizException.class).hasMessageContaining("不属于本次管理域"); } - // ==================== V46:应用设置行把关 ==================== + // ==================== V72:平台管理域把关(取代原 V46 模块行把关) ==================== @Test - @DisplayName("V46 把关:角色设置面摊 /admin/app 行;旧字典行 /admin/dicts 不再给任何管理权") - void appSettingsModuleRowIsTheGate() { - BoaUser withAppSettings = schoolUserWith(FormAdminGuard.MODULE_APP_SETTINGS); - assertThat(adminGuard.requireAdmin(withAppSettings, null, FormAdminGuard.MODULE_APP_SETTINGS)) - .isEqualTo(SCH); + @DisplayName("V72 把关:两表维护面按 C4③ 角色门(校管理员 ∪ 超管),不再消费任何 UserBase 模块行") + void platformAdminRoleIsTheGate() { + // 天然校领导(roleId 7)在校管理员补授表里没有任何行,也照样进得来——兜底那半是 §2.2 的语义 + BoaUser leader = schoolUser(); + assertThat(adminGuard.requirePlatformAdmin(leader, null)).isEqualTo(SCH); - BoaUser onlyOldDictsRow = schoolUserWith("/admin/dicts"); - assertThatThrownBy(() -> adminGuard.requireAdmin(onlyOldDictsRow, null, - FormAdminGuard.MODULE_APP_SETTINGS)) - .as("六行版遗留的 /admin/dicts 授权行在新口径下不再被任何守卫消费") - .isInstanceOf(BizException.class) - .hasMessageContaining(FormAdminGuard.MODULE_APP_SETTINGS); - } + BoaUser teacher = new BoaUser(); + teacher.setId(USER_SEQ.incrementAndGet()); + teacher.setType(0); + teacher.setRoleIds(new ArrayList<>(Collections.singletonList(1))); + BoaRoleInfo current = new BoaRoleInfo(); + current.setRoleId(1); + current.setSchId((int) SCH); + teacher.setCurrentRole(current); + assertThatThrownBy(() -> adminGuard.requirePlatformAdmin(teacher, null)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("无平台管理权"); + }); - @Test - @DisplayName("V46 把关:单表单管理员面摊 /admin/forms 行,只有应用设置行的人管不了表单管理员") - void formAdminEndpointNeedsFormDesignRow() { - BoaUser onlyAppSettings = schoolUserWith(FormAdminGuard.MODULE_APP_SETTINGS); - assertThatThrownBy(() -> adminGuard.requireAdmin(onlyAppSettings, null, - FormAdminGuard.MODULE_FORM_DESIGN)) - .isInstanceOf(BizException.class) - .hasMessageContaining(FormAdminGuard.MODULE_FORM_DESIGN); + // school_admin 表是补授通道:进去了就是校管理员,能进维护面 + grantSchoolAdmin(teacher.getId(), SCH); + assertThat(adminGuard.requirePlatformAdmin(teacher, null)).isEqualTo(SCH); } // ==================== 小工具 ==================== @@ -199,8 +201,8 @@ class AdminDomainIT extends AnswerTestSupport { return item; } - /** 只授权指定模块行的会话用户(roleId=7 但模块行另算——P21 起角色不给管理权);每次一个独立 id,防同测试内两个反例串权限 */ - private BoaUser schoolUserWith(String... paths) { + /** 校管理员形态的会话用户(roleId=7 天然命中校领导临时清单,两表里刻意不留行——测的就是"天然"那半) */ + private BoaUser schoolUser() { BoaUser user = new BoaUser(); user.setId(USER_SEQ.incrementAndGet()); user.setType(0); @@ -209,7 +211,6 @@ class AdminDomainIT extends AnswerTestSupport { current.setRoleId(7); current.setSchId((int) SCH); user.setCurrentRole(current); - moduleTree.grantPaths(user.getId(), paths); return user; } } diff --git a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java index 095c448..c08a415 100644 --- a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java @@ -144,37 +144,35 @@ class AdminScopeIT extends AnswerTestSupport { } @Test - @DisplayName("V44 权利=查看+导出:导出行数与列表同集;管理面入口仍要模块行(两表不给维护权)") - void formAdminGetsReadAndExportButNotMaintenance() { + @DisplayName("V44 权利=查看+导出:form_admin 的导出行数与列表同集(V27 同一条构造器)") + void formAdminExportMatchesList() { Seed seed = seed(); BoaUser teacher = user(ReadFixture.TEACHER_2, SCH, 1); grantFormAdmin(seed.ownUuid, ReadFixture.TEACHER_2, SCH); - // 数据面入口:两表不充当入口凭证,所以要显式给数据管理模块行(守卫红线,任务书 §0-4) - moduleTree.grantPaths(ReadFixture.TEACHER_2, FormAdminGuard.MODULE_DATA_MANAGE); ReadModels.QueryRequest request = new ReadModels.QueryRequest(); request.setFormUuid(seed.ownUuid); long exported = exportService.export(teacher, SCH, request, new CountingOutputStream()); assertThat(exported).as("导出行数=列表可见集大小(V27 同一条构造器)") .isEqualTo(visible(seed.ownUuid, teacher, SCH).size()); - - // 定义面/字典面仍拒:只有数据管理行的人碰不到表单设计与应用设置 - assertThatThrownBy(() -> adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_FORM_DESIGN)) - .isInstanceOf(BizException.class).hasMessageContaining("无该模块权限"); - assertThatThrownBy(() -> adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_APP_SETTINGS)) - .hasMessageContaining(FormAdminGuard.MODULE_APP_SETTINGS); } @Test - @DisplayName("V44 管理动作不受两表影响:没被授权数据管理行的人,改了范围也进不了管理面") - void adminTablesDoNotOpenMaintenance() { + @DisplayName("C4/G1 边界:form_admin 只升数据面——数据面入口进得来,定义面与平台管理面照样拒") + void formAdminOpensDataFaceOnly() { Seed seed = seed(); BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1); grantFormAdmin(seed.ownUuid, ReadFixture.TEACHER_1, SCH); - assertThatThrownBy(() -> adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE)) - .as("答卷管理动作的入口凭证仍是模块行") - .isInstanceOf(BizException.class) - .hasMessageContaining("无该模块权限"); + Form own = defService.findFormForGuard(seed.ownUuid); + + // 数据面读写入口=该表单 ALL 档(C4②),form_admin 命中即持有——模块行那套入口凭证已退役 + assertThat(adminGuard.requireData(teacher, SCH, own)).isEqualTo(SCH); + + // 定义域写(表单设计)与平台管理域(两表/应用设置)都要求超管∪校管理员(创建人另算,教师不是) + assertThatThrownBy(() -> adminGuard.requireDefinition(teacher, SCH, own)) + .isInstanceOf(BizException.class).hasMessageContaining("无该表单定义权"); + assertThatThrownBy(() -> adminGuard.requirePlatformAdmin(teacher, null)) + .isInstanceOf(BizException.class).hasMessageContaining("无平台管理权"); } // ==================== V47:加列 ==================== diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java index 87b591e..c268b36 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java @@ -9,6 +9,7 @@ import com.yunhai.formcenter.model.FormModels; import com.yunhai.formcenter.repository.AnswerOpLogRepository; import com.yunhai.formcenter.repository.StatusLogRepository; import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -236,12 +237,16 @@ class AnswerAdminIT extends AnswerTestSupport { req.setAnswerId(answerId); req.setStatus("done"); - // 控制器走的就是这一行:无管理权的填表人到不了 service + // 控制器走的就是这一行(C4② 数据面入口=该表单 ALL 档):无 ALL 档的填表人到不了 service for (BoaUser submitter : Arrays.asList(plainUser(3), plainUser(1))) { - assertThatThrownBy(() -> adminGuard.requireAdmin(submitter, null, FormAdminGuard.MODULE_DATA_MANAGE)) - .isInstanceOf(BizException.class).hasMessageContaining("无该模块权限"); + assertThatThrownBy(() -> adminGuard.requireDataByAnswer(submitter, null, answerId)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("无该表单数据权"); + }); } - assertThat(adminGuard.requireAdmin(adminUser(), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); + // 校管理员(roleId 7 命中校领导临时清单)= ALL 档,进得来 + assertThat(adminGuard.requireDataByAnswer(adminUser(), null, answerId)).isEqualTo(SCH); } // ==================== 批量 ==================== diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java index 1aa3bfc..88d220c 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java @@ -399,17 +399,25 @@ class AnswerReadIT extends AnswerTestSupport { .isInstanceOf(BizException.class).hasMessageContaining("不在你的数据范围内"); assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Row)) .isInstanceOf(BizException.class).hasMessageContaining("答卷不存在"); - // 教师被授权数据管理模块行也改不动别人的行(V39 范围半边:批量逐条判) + // V39 的范围半边换了载体:入口按 ALL 档把关(C4②),进得来之后逐条仍过同一条范围构造器。 + // 同一张表上"ALL 档持有者改别人的行"从此是**允许**的(form_admin 的语义就是该表全部行), + // 所以逐条判的可达形态只剩两种——①混入不存在/别域的 id(下面这条), + // ②租户限定变体把存量 NULL 行挡在行集外(AdminScopeIT.schoolAdminSeesOnlyOwnSchoolAnswersOnBaseForm)。 BoaUser teacher = fixture.teacher1(); - moduleTree.grantPaths(ReadFixture.TEACHER_1, FormAdminGuard.MODULE_DATA_MANAGE); - adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE); + grantFormAdmin(rows.formAUuid, ReadFixture.TEACHER_1, SCH); + adminGuard.requireDataByAnswers(teacher, SCH, Arrays.asList(rows.teacher1Row, rows.teacher2Row)); AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest(); - batch.setAnswerIds(Arrays.asList(rows.teacher1Row, rows.teacher2Row)); + String ghost = "ffffffffffffffffffffffffffffffff"; + batch.setAnswerIds(Arrays.asList(rows.teacher1Row, ghost, rows.teacher2Row)); batch.setStatus("pending"); List results = adminService.batchChangeStatus(teacher, SCH, batch); - assertThat(results.get(0).getSuccess()).isTrue(); - assertThat(results.get(1).getSuccess()).isFalse(); - assertThat(results.get(1).getMsg()).contains("不在你的数据范围内"); + assertThat(results.get(0).getSuccess()).as("ALL 档持有者改别人的行=允许(该表全部行)").isTrue(); + assertThat(results.get(1).getSuccess()).as("不存在的 id 只让那一条失败").isFalse(); + assertThat(results.get(1).getMsg()).contains("答卷不存在或已删除"); + assertThat(results.get(2).getSuccess()).isTrue(); + // 教师的 SELF/ALL 判定不因这次批量而变宽:他管的还是这张表(form_admin 只授到该 uuid) + assertThatThrownBy(() -> adminGuard.requireDataByAnswer(teacher, SCH, rows.formBUuid)) + .isInstanceOf(BizException.class); } // ==================== P25:遮蔽后的钉版溯源 ==================== diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java index e061cd0..b1df020 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java @@ -27,10 +27,10 @@ import java.util.concurrent.atomic.AtomicInteger; * 测试直接造它——这正是 V17 的断言方式:服务签名里只有 {@code BoaUser} 与请求体, * 请求体没有 {@code schId/submitterId} 字段,身份只能来自会话对象。

      * - *

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

      + *

      外部事实源用桩顶掉({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS): + * {@link SubjectDirectory}(P33 的代填/代录存在性,生产实现是平台签名调用)。 + * 契约 v2 之后没有第三个桩了:原 {@code ModuleTreeSource}(授权模块集)随模块行整条退役删除, + * 管理权与数据范围的事实源都落在本库(角色 + 两表 + creator_id),IT 直接插真行。

      */ abstract class AnswerTestSupport { @@ -60,9 +60,6 @@ abstract class AnswerTestSupport { @Autowired protected AnswerIndexService indexService; - @Autowired - protected InMemoryModuleTreeSource moduleTree; - @Autowired protected InMemorySubjectDirectory subjects; @@ -74,7 +71,6 @@ abstract class AnswerTestSupport { @BeforeEach void resetGateways() { - moduleTree.reset(); subjects.reset(); } @@ -101,20 +97,17 @@ abstract class AnswerTestSupport { } /** - * 管理员操作人(roleId=7 + 被授权数据管理/定义管理模块行)。 + * 管理员操作人(契约 v2 C4 之后=roleId 7 命中校领导临时清单,天然校管理员)。 * - *

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

      + *

      模块行退役后没有"再授权一次"的第二件事:管理权与数据范围来自同一份角色/所有权事实。 + * 需要"不是校管理员"的反例时用 {@link #user(Integer, Long, Integer)} 配一个不在清单里的 roleId。

      */ protected BoaUser manager() { return manager(MANAGER_ID); } protected BoaUser manager(Integer id) { - BoaUser user = user(id, SCH, 7, 2); - moduleTree.grantManageDefaults(id); - return user; + return user(id, SCH, 7, 2); } /** 指定 id 的会话用户(并发/限次用例需要"同一个人"稳定复现) */ @@ -127,10 +120,6 @@ abstract class AnswerTestSupport { current.setRoleId(roleId); current.setSchId(schId.intValue()); user.setCurrentRole(current); - if (roleId != null && roleId == 7) { - // 管理员在测试语义里=被授权中心模块行的人(手册 §2.2);反例显式 clear - moduleTree.grantManageDefaults(id); - } return user; } @@ -228,12 +217,6 @@ abstract class AnswerTestSupport { @TestConfiguration static class TestGatewayConfiguration { - @Bean - @Primary - InMemoryModuleTreeSource inMemoryModuleTreeSource() { - return new InMemoryModuleTreeSource(); - } - @Bean @Primary InMemorySubjectDirectory inMemorySubjectDirectory() { diff --git a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java index 84585d9..3d78bdb 100644 --- a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java +++ b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java @@ -1,269 +1,302 @@ package com.yunhai.formcenter.service; +import com.yunhai.formcenter.entity.Answer; import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FormRepository; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.utils.model.BoaModule; +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 java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** - * 管理权闸门单测(执行手册 §5 P21 裁决的 T4 到期替换:授权模块集口径,roleId 硬编码整体移除; - * T10 再改:字典管理行撤、应用设置行接手 + 数据面入口判定 {@code requireDataView})。 + * 管理面守卫单测(契约 v2 C4,验收 V72 的守卫半边)。 * - *

      口径:能进哪个管理面=被授权哪个模块行(手册 §2.2 表 v1.5 五行版:定义管理 {@code /admin/forms}、 - * 数据管理 {@code /admin/data}、统计 {@code /admin/stats}、应用设置 {@code /admin/app}、我的填写 - * {@code /my});超管({@code type==1},UserBase 用户类型不是 roleId)仍可跨校且唯一可管 - * {@code sch_id=0} 平台级行(方案 §6.5"超管不受单校开通约束")。

      + *

      原 T4~T10 形态的"授权模块行→能不能进页面"用例({@code ModuleTreeSource} 桩 + path 匹配) + * 随模块行整条退役删除(§19 作废-10)。这里改按 C4 三段角色门逐格断言,并且把租户域推导 + * 一并钉住——那半套规则(超管可指定含 0 的目标域、校级只能本校且传 0 即拒)从 T4 起一字未动, + * 换的只是"进不进得来"的判据来源。

      * - *

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

      + *

      两表与创建人判定都用真实现({@link InMemoryAdminScopeSource} + {@link DataScopeService}), + * 只有"按答卷反查表单"用到仓库(Mockito 桩)——守卫的逻辑本身一行都不允许是桩。

      */ -@DisplayName("FormAdminGuard - 授权模块集口径与数据面入口判定(P21/T10)") +@DisplayName("FormAdminGuard - C4 三段角色门与租户域推导(V72)") class FormAdminGuardTest { - private static final long SCH = 880001L; - private static final long OTHER_SCH = 880002L; + private static final long SCH = 990301L; + private static final long OTHER_SCH = 990302L; + private static final String FORM_UUID = "f-uuid-0001"; + + private final InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource(); + private final FormAccessPolicy policy = new FormAccessPolicy(admins); + private final AnswerRepository answerRepository = mock(AnswerRepository.class); + private final FormRepository formRepository = mock(FormRepository.class); + private final DataScopeService dataScope = new DataScopeService( + Arrays.asList(new AllScopeImplementor(), new SelfScopeImplementor()), admins, policy); + private final FormAdminGuard guard = + new FormAdminGuard(policy, dataScope, answerRepository, formRepository); + + // ==================== C4① 定义域 ==================== @Test - @DisplayName("被授权数据管理模块行=可进数据面;同 roleId 但未授权=拒(管理权不再来自角色)") - void moduleGrantIsTheGate() { - FormAdminGuard guard = guardWith("/admin/data"); - assertThat(guard.requireAdmin(schoolUser(7), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); - // 教师被授权了数据管理行同样能进(第②层与第③层正交:能进接口≠能看全部行,行级范围另判) - assertThat(guard.requireAdmin(schoolUser(1), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); + @DisplayName("新建/另存副本=超管 ∪ 校管理员(G1);教师/学生/家长一律拒,且拒绝文案点名要求的角色") + void createRequiresSuperOrSchoolAdmin() { + assertThat(guard.requireCreate(superAdmin(), null)).isEqualTo(0L); + assertThat(guard.requireCreate(user(7), null)).isEqualTo(SCH); + assertThat(guard.requireCreate(user(2), null)).isEqualTo(SCH); + admins.grantSchoolAdmin(SCH, 30003); + assertThat(guard.requireCreate(user(3), SCH)).isEqualTo(SCH); - FormAdminGuard none = guardWith(); - for (int roleId : Arrays.asList(1, 2, 3, 5, 6, 7)) { - assertThatThrownBy(() -> none.requireAdmin(schoolUser(roleId), null, - FormAdminGuard.MODULE_DATA_MANAGE)) - .as("roleId=" + roleId + " 未授权模块行") - .isInstanceOf(BizException.class) - .hasMessageContaining("无该模块权限"); + for (int roleId : new int[]{1, 4, 5, 6}) { + admins.reset(); + assertThatThrownBy(() -> guard.requireCreate(user(roleId), null)) + .as("roleId=" + roleId) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("无新建表单权").contains("超管或校管理员"); + }); } } @Test - @DisplayName("四个模块行互不通用:只有定义管理权的人调应用设置面(含字典 API)被拒(口径=手册 §2.2 一行一权)") - void modulesAreNotInterchangeable() { - FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_FORM_DESIGN); - assertThatCode(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_FORM_DESIGN)) - .doesNotThrowAnyException(); - assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_APP_SETTINGS)) - .hasMessageContaining(FormAdminGuard.MODULE_APP_SETTINGS); - assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_STATS)) - .hasMessageContaining(FormAdminGuard.MODULE_STATS); + @DisplayName("C4①:创建人(creator_id 命中)对本人所建表单有定义权,即便两表里根本没有他") + void creatorHoldsDefinitionOnOwnForm() { + BoaUser teacher = user(1); + // 先坐实:教师身份 + 别人建的表 = 定义域进不去(创建人那半不成立) + assertThatThrownBy(() -> guard.requireDefinition(teacher, SCH, form(SCH, 12345))) + .isInstanceOf(BizException.class).hasMessageContaining("无该表单定义权"); + + Form own = form(SCH, 12345); + own.setCreatorId(Long.valueOf(teacher.getId())); + assertThat(guard.requireDefinition(teacher, SCH, own)).isEqualTo(SCH); } @Test - @DisplayName("统计的\"二选一\"配法:授权 /admin/stats 或 /admin/data 任一即可(手册 §2.2 统计可并入数据管理)") - void statsAcceptsEitherModuleRow() { - assertThat(guardWith(FormAdminGuard.MODULE_STATS) - .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, - FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); - assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE) - .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, - FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); - assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_APP_SETTINGS) - .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, - FormAdminGuard.MODULE_DATA_MANAGE)) - .isInstanceOf(BizException.class).hasMessageContaining("需任一"); + @DisplayName("C4① 兜底:creator_id 未回填的存量行按 create_by 账号名判(无会话账号时不成立,宁紧勿松)") + void legacyCreateByFallbackNeedsSessionAccount() { + BoaUser teacher = user(1); + Form legacy = form(SCH, teacher.getId()); + legacy.setCreatorId(null); + legacy.setCreateBy("some_account"); + assertThatThrownBy(() -> guard.requireDefinition(teacher, SCH, legacy)) + .isInstanceOf(BizException.class).hasMessageContaining("无该表单定义权"); } @Test - @DisplayName("path 空的权限位(快应用按钮锚点那种行)不给中心管理权——中心守卫只认页面路由行") - void blankPathAnchorIsNotAModuleRow() { - FormAdminGuard guard = new FormAdminGuard(user -> { - BoaModule anchor = new BoaModule(); - anchor.setCustomId("303"); // 快应用面的"导入"锚点形态:path 空 + 数字 customId - anchor.setChildren(new ArrayList()); - List rows = new ArrayList<>(); - rows.add(anchor); - return rows; - }); - assertThatThrownBy(() -> guard.requireAdmin(schoolUser(7), null, FormAdminGuard.MODULE_DATA_MANAGE)) - .hasMessageContaining("无该模块权限"); - assertThat(guard.canManage(schoolUser(7))).isFalse(); + @DisplayName("定义域拒绝面:他校表单按\"表单不存在\"回答(存在性就是探测面),非创建人非管理员按 FORBIDDEN") + void definitionRejectsForeignFormAsMissing() { + BoaUser leader = user(7); + assertThatThrownBy(() -> guard.requireDefinition(leader, SCH, form(OTHER_SCH, 12345))) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR); + assertThat(e.getMessage()).contains("表单不存在"); + }); + assertThatThrownBy(() -> guard.requireDefinition(user(1), SCH, form(SCH, 12345))) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + } + + // ==================== C4② 数据域 ==================== + + @Test + @DisplayName("C4②:数据面入口=该表单 ALL 档(form_admin 命中即可,不需要任何模块行)") + void dataEntrypointNeedsAllTier() { + BoaUser teacher = user(1); + Form own = form(SCH, 12345); + assertThatThrownBy(() -> guard.requireData(teacher, SCH, own)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("无该表单数据权").contains("ALL 档"); + }); + + admins.grantFormAdmin(FORM_UUID, teacher.getId()); + assertThat(guard.requireData(teacher, SCH, own)).isEqualTo(SCH); } @Test - @DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有应用设置没有;未授权没有") - void canManageFollowsModuleSet() { - assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE).canManage(schoolUser(1))).isTrue(); - assertThat(guardWith(FormAdminGuard.MODULE_FORM_DESIGN).canManage(schoolUser(1))).isTrue(); - assertThat(guardWith(FormAdminGuard.MODULE_APP_SETTINGS).canManage(schoolUser(7))).isFalse(); - assertThat(guardWith().canManage(schoolUser(7))).isFalse(); + @DisplayName("C4②:校管理员在平台级表单上是 ALL 档的租户限定变体,照样算入口持有者(行级由构造器收窄)") + void schoolAdminEntersPlatformFormViaTenantScopedAll() { + assertThat(guard.requireData(user(7), SCH, form(Form.PLATFORM_SCH_ID, 12345))).isEqualTo(SCH); } @Test - @DisplayName("校级管理员指定他校 schId 被拒(含把 schId 传成 0 想碰平台级行)") - void schoolAdminCannotCrossDomain() { - FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE); - BoaUser admin = schoolUser(7); - assertThatThrownBy(() -> guard.requireAdmin(admin, 999999L, FormAdminGuard.MODULE_DATA_MANAGE)) - .isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校"); - assertThatThrownBy(() -> guard.requireAdmin(admin, 0L, FormAdminGuard.MODULE_DATA_MANAGE)) - .hasMessageContaining("仅超管可维护"); + @DisplayName("C4②:表单取不到与\"不是你的表单\"同口径回答 PARAM(uuid 不可枚举);他校域外表单同口径") + void dataEntrypointFailsClosedOnFormLookup() { + BoaUser leader = user(7); + assertThatThrownBy(() -> guard.requireData(leader, SCH, null)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR); + assertThat(e.getMessage()).isEqualTo("表单不存在"); + }); + assertThatThrownBy(() -> guard.requireData(leader, SCH, form(OTHER_SCH, 12345))) + .isInstanceOf(BizException.class).hasMessageContaining("表单不存在"); } @Test - @DisplayName("超管可显式指定任意域(含 0=平台级),未被授权模块行也能进;无当前身份时必须显式给域") - void superAdminBypassesModuleRows() { - FormAdminGuard guard = guardWith(); - BoaUser root = new BoaUser(); - root.setId(1); - root.setType(1); - assertThat(guard.requireAdmin(root, 0L, FormAdminGuard.MODULE_APP_SETTINGS)).isEqualTo(0L); - assertThat(guard.requireAdmin(root, SCH, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); - root.setCurrentRole(role(3, (int) SCH)); - assertThat(guard.requireAdmin(root, null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); - assertThat(guard.canManage(root)).isTrue(); + @DisplayName("按答卷寻址的数据面入口(edit/softDelete/changeStatus/detail):反查所属表单后判 ALL 档") + void answerAddressedEntrypointsResolveFormFirst() { + String answerUuid = "a-uuid-0001"; + stubAnswer(answerUuid, FORM_UUID); + BoaUser teacher = user(1); - BoaUser rootWithoutSchool = new BoaUser(); - rootWithoutSchool.setId(1); - rootWithoutSchool.setType(1); - assertThatThrownBy(() -> guard.requireAdmin(rootWithoutSchool, null, - FormAdminGuard.MODULE_DATA_MANAGE)).hasMessageContaining("超管请显式指定 schId"); + assertThatThrownBy(() -> guard.requireDataByAnswer(teacher, SCH, answerUuid)) + .isInstanceOf(BizException.class).hasMessageContaining("无该表单数据权"); + + admins.grantFormAdmin(FORM_UUID, teacher.getId()); + assertThat(guard.requireDataByAnswer(teacher, SCH, answerUuid)).isEqualTo(SCH); } @Test - @DisplayName("无会话=UNAUTHORIZED(不是 FORBIDDEN)——契约 §6 定稿映射的未登录段") - void missingSessionIsUnauthorized() { - FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE); - assertThatThrownBy(() -> guard.requireAdmin(null, null, FormAdminGuard.MODULE_DATA_MANAGE)) - .isInstanceOf(BizException.class).hasMessageContaining("未登录"); - BoaUser anonymous = new BoaUser(); - assertThatThrownBy(() -> guard.requireAdmin(anonymous, null, FormAdminGuard.MODULE_DATA_MANAGE)) - .hasMessageContaining("未登录"); - assertThatThrownBy(() -> guard.requireAdminAny(anonymous, null, FormAdminGuard.MODULE_STATS)) - .hasMessageContaining("未登录"); - assertThatThrownBy(() -> guard.requireDataView(anonymous, null, form(SCH, "f-a"))) - .hasMessageContaining("未登录"); - assertThat(guard.canManage(null)).isFalse(); - assertThat(guard.canManage(anonymous)).isFalse(); + @DisplayName("批量入口:跨表单的批量里任意一张不在 ALL 档内即整把拒(V14\"填表人不能改状态\"不从批量漏出)") + void batchEntrypointRequiresAllOnEveryForm() { + Form inSchool = form(SCH, 12345); + Answer first = answer("a-1", FORM_UUID); + Answer second = answer("a-2", "f-uuid-0002"); + when(answerRepository.findBySchIdAndUuidIn(anyLong(), any())) + .thenReturn(Arrays.asList(first, second)); + when(formRepository.findByUuidIn(any())) + .thenReturn(Arrays.asList(inSchool, form(SCH, 12345, "f-uuid-0002"))); + + BoaUser teacher = user(1); + admins.grantFormAdmin(FORM_UUID, teacher.getId()); + List ids = Arrays.asList("a-1", "a-2"); + assertThatThrownBy(() -> guard.requireDataByAnswers(teacher, SCH, ids)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("批量里每张表都要求 ALL 档"); + }); + + // 两把钥匙都配上之后放行,且逐条范围仍由 AnswerScopeQueryBuilder 把关(V39 的租户限定变体场景) + admins.grantFormAdmin("f-uuid-0002", teacher.getId()); + assertThat(guard.requireDataByAnswers(teacher, SCH, ids)).isEqualTo(SCH); + } + + // ==================== C4③ 平台管理域 ==================== + + @Test + @DisplayName("C4③:两表/app 注册/GetSchoolAll=校管理员 ∪ 超管;form_admin 不够(它只升数据范围)") + void platformAdminNeedsSchoolAdminOrSuper() { + assertThat(guard.requirePlatformAdmin(superAdmin(), null)).isEqualTo(0L); + assertThat(guard.requirePlatformAdmin(user(7), null)).isEqualTo(SCH); + BoaUser student = user(3); + admins.grantSchoolAdmin(SCH, student.getId()); + assertThat(guard.requirePlatformAdmin(student, SCH)).isEqualTo(SCH); + + // 反例:只有 form_admin(单表单 ALL 档)的人不进平台管理域——两表权限不外溢到维护面 + admins.reset(); + BoaUser teacher = user(1); + admins.grantFormAdmin(FORM_UUID, teacher.getId()); + assertThatThrownBy(() -> guard.requirePlatformAdmin(teacher, SCH)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("无平台管理权"); + }); + } + + // ==================== 租户域推导(T4 起口径原样承继) ==================== + + @Test + @DisplayName("域推导:超管显式目标域优先(含 0=平台级)、无学校归属要求显式指定;校级传 0 或他校即拒") + void domainDerivationKeepsT4Rules() { + BoaUser root = superAdmin(); + assertThat(guard.requireCreate(root, OTHER_SCH)).isEqualTo(OTHER_SCH); + assertThat(guard.requireCreate(root, 0L)).isEqualTo(0L); + BoaUser rootNoSchool = superAdmin(); + rootNoSchool.setCurrentRole(null); + assertThatThrownBy(() -> guard.requireCreate(rootNoSchool, null)) + .isInstanceOf(BizException.class).hasMessageContaining("超管请显式指定 schId"); + + assertThatThrownBy(() -> guard.requireCreate(user(7), 0L)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("平台级表单/字典仅超管可维护"); + }); + assertThatThrownBy(() -> guard.requireCreate(user(7), OTHER_SCH)) + .isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校表单与字典"); } @Test - @DisplayName("有当前身份但无学校归属(区域组/根直属)不算管理权——不给出「本校」域") - void noSchoolBindingIsNotAdmin() { - FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE); + @DisplayName("fail-closed:未登录 UNAUTHORIZED;有角色但无学校归属的校管理员 FORBIDDEN(给不出\"本校\"域就不能放行)") + void sessionAndSchoolRequired() { + assertThatThrownBy(() -> guard.requireCreate(null, null)) + .isInstanceOfSatisfying(BizException.class, + e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED)); + BoaUser leaderNoSchool = user(7); + leaderNoSchool.setCurrentRole(null); + assertThatThrownBy(() -> guard.requireCreate(leaderNoSchool, null)) + .isInstanceOfSatisfying(BizException.class, e -> { + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN); + assertThat(e.getMessage()).contains("无学校归属,不能确定管理域"); + }); + } + + // ==================== 构造小工具 ==================== + + private void stubAnswer(String answerUuid, String formUuid) { + when(answerRepository.findBySchIdAndUuidIn(anyLong(), any())) + .thenReturn(Collections.singletonList(answer(answerUuid, formUuid))); + when(formRepository.findByUuidIn(any())).thenReturn(Collections.singletonList(form(SCH, 12345, formUuid))); + } + + private static Answer answer(String uuid, String formUuid) { + Answer answer = new Answer(); + answer.setUuid(uuid); + answer.setFormUuid(formUuid); + answer.setSchId(SCH); + return answer; + } + + private static Form form(Long schId, Integer creatorId) { + return form(schId, creatorId, FORM_UUID); + } + + private static Form form(Long schId, Integer creatorId, String uuid) { + Form form = new Form(); + form.setUuid(uuid); + form.setSchId(schId); + form.setAppCode(Form.CENTER_APP_CODE); + form.setCode("guard_form"); + form.setName("守卫用例表单"); + form.setCreatorId(creatorId == null ? null : Long.valueOf(creatorId)); + return form; + } + + private static BoaUser user(int roleId) { BoaUser user = new BoaUser(); - user.setId(10133); - user.setRoleIds(Collections.singletonList(7)); - user.setCurrentRole(role(7, null)); - assertThatThrownBy(() -> guard.requireAdmin(user, null, FormAdminGuard.MODULE_DATA_MANAGE)) - .hasMessageContaining("无学校归属"); - assertThat(guard.canManage(user)).isFalse(); - - BoaUser full = schoolUser(7); - assertThatCode(() -> guard.requireAdmin(full, SCH, FormAdminGuard.MODULE_DATA_MANAGE)) - .doesNotThrowAnyException(); - } - - // ==================== V46:数据面入口判定 requireDataView(T10 方案 §5.2) ==================== - - @Test - @DisplayName("V46 数据面入口:本校表单只要持有数据管理或统计任一行即放行,域=本校") - void dataViewAcceptsEitherModuleRowOnOwnForm() { - assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE) - .requireDataView(schoolUser(1), null, form(SCH, "f-own"))).isEqualTo(SCH); - assertThat(guardWith(FormAdminGuard.MODULE_STATS) - .requireDataView(schoolUser(1), null, form(SCH, "f-own"))).isEqualTo(SCH); - assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_APP_SETTINGS) - .requireDataView(schoolUser(1), null, form(SCH, "f-own"))) - .isInstanceOf(BizException.class).hasMessageContaining("需任一"); - } - - @Test - @DisplayName("V46 基础表单(sch_id=0)在本校域下可读:入口不看管理员两表(守卫红线:两表不作入口凭证)," - + "能看哪些行由 DataScopeService 判") - void dataViewOpensBaseFormOnOwnDomain() { - FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE); - assertThat(guard.requireDataView(schoolUser(1), null, form(Form.PLATFORM_SCH_ID, "f-base"))) - .isEqualTo(SCH); - // 但把目标域显式传成 0 仍是超管专属(平台级行的维护口径不变) - assertThatThrownBy(() -> guard.requireDataView(schoolUser(1), 0L, form(Form.PLATFORM_SCH_ID, "f-base"))) - .isInstanceOf(BizException.class).hasMessageContaining("仅超管可维护"); - } - - @Test - @DisplayName("V46 他校表单与不存在的表单同一口径回答(PARAM_ERROR 表单不存在,不给\"无权限\"这种差别信息)") - void dataViewTreatsForeignFormAsAbsent() { - FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE); - assertThatThrownBy(() -> guard.requireDataView(schoolUser(7), null, form(OTHER_SCH, "f-other"))) - .isInstanceOf(BizException.class) - .hasMessageContaining("表单不存在"); - assertThatThrownBy(() -> guard.requireDataView(schoolUser(7), null, null)) - .hasMessageContaining("表单不存在"); - } - - @Test - @DisplayName("V46 超管的数据面入口域语义与 requireAdmin 一致:显式目标域优先(含 0=平台级),不给更窄默认") - void dataViewKeepsSuperAdminDomainSemantics() { - FormAdminGuard guard = guardWith(); - BoaUser root = new BoaUser(); - root.setId(1); - root.setType(1); - assertThat(guard.requireDataView(root, SCH, form(Form.PLATFORM_SCH_ID, "f-base"))).isEqualTo(SCH); - assertThat(guard.requireDataView(root, 0L, form(Form.PLATFORM_SCH_ID, "f-base"))).isEqualTo(0L); - } - - @Test - @DisplayName("V46 数据面入口:有当前身份但无学校归属=拒(给不出查询域就没有范围可解析)") - void dataViewNeedsSchoolBinding() { - BoaUser user = new BoaUser(); - user.setId(10134); - user.setRoleIds(Collections.singletonList(1)); - user.setCurrentRole(role(1, null)); - assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_DATA_MANAGE) - .requireDataView(user, null, form(SCH, "f-own"))) - .isInstanceOf(BizException.class).hasMessageContaining("无学校归属"); - } - - /** 授权集合直给的守卫(模块行 path 变参;path 空行不在此列,见权限位用例) */ - private static FormAdminGuard guardWith(String... paths) { - return new FormAdminGuard(user -> { - List rows = new ArrayList<>(); - for (String path : paths) { - BoaModule module = new BoaModule(); - module.setPath(path); - module.setChildren(new ArrayList()); - rows.add(module); - } - return rows; - }); - } - - private BoaUser schoolUser(int roleId) { - BoaUser user = new BoaUser(); - user.setId(10133); - user.setType(2); - user.setRoleIds(Arrays.asList(roleId)); + user.setId(30000 + roleId); + user.setType(0); + user.setRoleIds(Collections.singletonList(roleId)); user.setCurrentRole(role(roleId, (int) SCH)); return user; } - private BoaRoleInfo role(Integer roleId, Integer schId) { + private static BoaUser superAdmin() { + BoaUser user = user(9); + user.setType(1); + user.setCurrentRole(role(9, 0)); + return user; + } + + private static BoaRoleInfo role(Integer roleId, Integer schId) { BoaRoleInfo info = new BoaRoleInfo(); info.setRoleId(roleId); info.setSchId(schId); return info; } - - private static Form form(long schId, String uuid) { - Form form = new Form(); - form.setSchId(schId); - form.setUuid(uuid); - return form; - } } diff --git a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java b/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java deleted file mode 100644 index aac7f1a..0000000 --- a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.yunhai.formcenter.service; - -import com.yunhai.platform.utils.model.BoaModule; -import com.yunhai.platform.utils.model.BoaUser; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的管理权口径注入这里取证, - * 真实链路 T9 联调验)。 - * - *

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

      - * - *

      T10 起本桩只剩 path 一种形态:{@code customId} 权限锚点协议退役,数据范围升级改由管理员两表提供 - * ({@link InMemoryAdminScopeSource})。

      - */ -public class InMemoryModuleTreeSource implements ModuleTreeSource { - - private final Map> byUser = new ConcurrentHashMap<>(); - - /** 授权页面路由模块行(path 逐字用手册 §2.2 的那几条) */ - public void grantPaths(Integer userId, String... paths) { - List rows = rows(userId); - for (String path : paths) { - rows.add(row(path)); - } - } - - /** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */ - public void clear(Integer userId) { - byUser.remove(userId); - } - - /** 每条用例前清空全部授权(脚手架 @BeforeEach 调它,防用例间串权限) */ - public void reset() { - byUser.clear(); - } - - @Override - public List authorizedModules(BoaUser user) { - if (user == null || user.getId() == null) { - return Collections.emptyList(); - } - // 桩返回扁平列表即可:FormModuleKeys 的递归遍历对 children=null 与树形都同样处理 - return Collections.unmodifiableList( - new ArrayList<>(byUser.getOrDefault(user.getId(), Collections.emptyList()))); - } - - private List rows(Integer userId) { - return byUser.computeIfAbsent(userId, key -> Collections.synchronizedList(new ArrayList<>())); - } - - private static BoaModule row(String path) { - BoaModule module = new BoaModule(); - module.setPath(path); - module.setName("模块 " + path); - module.setChildren(new ArrayList()); - return module; - } - - /** 便于用例一行授权"数据管理 + 定义管理"(=手册 §2.2 的中心管理员常见配法) */ - public void grantManageDefaults(Integer userId) { - grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN); - } -} diff --git a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java index 15adcdf..68227a9 100644 --- a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java +++ b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java @@ -108,8 +108,8 @@ class MyAnswerIT extends AnswerTestSupport { assertThat(mapped).containsExactlyInAnyOrder( "GET /list", "GET /submit-list", "GET /about-list", "GET /detail", "POST /delete", "POST /edit", - // P95 W3 新增的填报入口清单:同样只吃会话身份(appCode 是应用维度筛项,不是身份) - "GET /fillable"); + // 契约 v2 §2.4 的三支路自解析端点:同样只吃会话身份(appCode 是应用维度筛项,不是身份) + "GET /fillable", "GET /managed", "GET /capabilities"); // V14 的结构半边:本面没有任何改状态端点 assertThat(mapped).noneMatch(p -> p.contains("status")); assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId"); @@ -299,13 +299,13 @@ class MyAnswerIT extends AnswerTestSupport { selfFill.setSubjectId(studentId); String answerId = answerService.submit(student, selfFill).getAnswerId(); - moduleTree.clear(studentId); assertThat(idsOf(controller.submitList(student))).containsExactly(answerId); assertThat(idsOf(controller.aboutList(student))).containsExactly(answerId); assertThat(controller.detail(student, answerId).getAnswerId()).isEqualTo(answerId); - // 对照:数据管理面要求 /admin/data 模块行,同一个人过去就是 FORBIDDEN(两套口径不混用) - assertThatThrownBy(() -> adminGuard.requireAdmin(student, SCH, FormAdminGuard.MODULE_DATA_MANAGE)) - .isInstanceOf(BizException.class); + // 对照:数据管理面入口按该表单 ALL 档把关(C4②),同一个人以填报人身份过去仍是 FORBIDDEN + // ——"我的面"能看能改自己的行,"管理面"要 ALL 档,两套口径不混用(V14/V17 同源断言) + assertThatThrownBy(() -> adminGuard.requireDataByAnswer(student, SCH, answerId)) + .isInstanceOf(BizException.class).hasMessageContaining("无该表单数据权"); } @Test diff --git a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java index 4457a3e..8d85c14 100644 --- a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java +++ b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java @@ -5,7 +5,6 @@ import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.FormModels; import com.yunhai.formcenter.model.ReadModels; -import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.DisplayName; @@ -57,7 +56,7 @@ import java.util.stream.Collectors; * searchable 四字段各写索引行(≈78 万),每 50 行置 1 行软删(让谓词真实参与)。

      * *

      口径边界(报告要点名):测的是 service+SQL 层延迟,不含 HTTP/CAS/JSON 序列化 - * 的用户面开销,也不含 UserBase 签名调用的网络耗时(模块树被桩顶掉;P44 单独量化每请求取树次数)。 + * 的用户面开销,也不含 UserBase 签名调用的网络耗时(契约 v2 起中心不再取模块树,P44 度量对象已消失)。 * 本机 MySQL 8.0.45 与 160 容器网 mysql8 同大版本。

      */ @SpringBootTest(properties = { @@ -66,7 +65,7 @@ import java.util.stream.Collectors; + "&allowPublicKeyRetrieval=true", "spring.jpa.hibernate.ddl-auto=update" }) -@DisplayName("T9 压测探针(V10 阈值 + P26 重号率 + P35 索引选择性 + P37 缓存语义 + P44 取树次数)") +@DisplayName("T9 压测探针(V10 阈值 + P26 重号率 + P35 索引选择性 + P37 缓存语义)") class Perf200kProbe { private static final long SCH = 990600L; @@ -109,32 +108,12 @@ class Perf200kProbe { private AnswerStatsService statsService; @Autowired private AnswerExportService exportService; - @Autowired - private CountingModuleTreeSource moduleTree; - private final List report = new ArrayList<>(); private final List sqlLog = new ArrayList<>(); private final AtomicInteger userSeq = new AtomicInteger(910000); - /** 模块树桩的计数版(P44):桩顶掉签名调用的同时把调用次数留下来,否则 P44 只能靠读代码猜 */ - static class CountingModuleTreeSource extends InMemoryModuleTreeSource { - final AtomicInteger calls = new AtomicInteger(); - - @Override - public List authorizedModules(BoaUser user) { - calls.incrementAndGet(); - return super.authorizedModules(user); - } - } - @TestConfiguration static class PerfGatewayConfiguration { - @Bean - @Primary - CountingModuleTreeSource countingModuleTreeSource() { - return new CountingModuleTreeSource(); - } - @Bean @Primary InMemorySubjectDirectory perfSubjectDirectory() { @@ -407,7 +386,7 @@ class Perf200kProbe { .filter(s -> s.toLowerCase().startsWith("select")).collect(Collectors.toList()); } - // ==================== P26 / P37 / P44 ==================== + // ==================== P26 / P37 ==================== private void p37CacheSemantics(BoaUser manager, String uuid) { ReadModels.QueryRequest req = new ReadModels.QueryRequest(); @@ -443,25 +422,11 @@ class Perf200kProbe { ReadModels.QueryRequest stats = new ReadModels.QueryRequest(); stats.setFormUuid(uuid); - // 详情用的 uuid 先取好——否则取首屏那一次 list 会被算进详情的计数里 - String detailUuid = cursorOfFirstRow(manager, list); - log("P44 每请求取树次数:列表=" + probe(() -> readService.list(manager, SCH, list)) - + " 统计=" + probe(() -> statsService.stats(manager, SCH, stats)) - + " 导出=" + probe(() -> exportService.export(manager, SCH, stats, new ByteArrayOutputStream())) - + " 详情=" + probe(() -> readService.detail(manager, SCH, detailUuid))); - log("P44 口径:阈值内(V10 p95 达标)按一期现状保持,不改成控制器内一次取树两处复用;" - + "生产侧每次取树=一次 UserBase 签名调用,网络耗时不在本探针口径内(类注释已记档)"); - } - - private String cursorOfFirstRow(BoaUser manager, ReadModels.ListRequest list) { - return readService.list(manager, SCH, list).getRows().get(0).getAnswerId(); - } - - /** 跑一次并返回这段时间里模块树被取了几次 */ - private int probe(Runnable body) { - int before = moduleTree.calls.get(); - body.run(); - return moduleTree.calls.get() - before; + // P44(每请求取 UserBase 模块树的次数)随契约 v2 模块行整条退役而失去度量对象: + // 管理面守卫不再取树(§19 作废-10),四条读路径的每请求外部调用只剩 SubjectDirectory 一类, + // 因此这里只保留 V10 的延迟读数,不再打取树计数。 + readService.list(manager, SCH, list); + statsService.stats(manager, SCH, stats); } /** P26:answer_no 当日序号 count+1 的重号率——并发真实提交才量得出来(不限次 + 报名号的长尾组合) */ @@ -513,7 +478,6 @@ class Perf200kProbe { user.setType(2); user.setRoleIds(Collections.singletonList(7)); user.setCurrentRole(role(7)); - moduleTree.grantManageDefaults(900001); return user; } diff --git a/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java b/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java deleted file mode 100644 index 70fc74e..0000000 --- a/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.yunhai.formcenter.service; - -import com.yunhai.formcenter.controller.api.FormAnswerReadController; -import com.yunhai.formcenter.model.ReadModels; -import com.yunhai.platform.utils.model.BoaModule; -import com.yunhai.platform.utils.model.BoaRoleInfo; -import com.yunhai.platform.utils.model.BoaUser; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.mock.web.MockHttpServletResponse; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * T9 压测补探针(控制器形状):{@code Perf200kProbe} 直调服务层,量到的"每请求取树次数"少了一次 - * 控制器侧 {@code FormAdminGuard.requireAdmin} 的取树——那正是 P44 问的东西("每请求 1~2 次"里的 2 从哪来)。 - * 本探针打 {@link FormAnswerReadController} 的端点方法(会话用户 + 请求体,与线上调用形状一致), - * 把 P44 的数字钉成请求级,并顺带给出 V10 三项在含守卫形状下的延迟。 - * - *

      数据复用 {@code bsch_formcenter_perf}(由 {@code Perf200kProbe} 造好,20 万答卷 / 78 万索引行)。 - * 命名不带 Test/IT 后缀 → 默认门禁不收集;手点:{@code mvn -B -o test -Dtest=PerfTreeCallsProbe}。

      - */ -@SpringBootTest(properties = { - "spring.datasource.url=jdbc:mysql://localhost:3306/bsch_formcenter_perf?createDatabaseIfNotExist=true" - + "&useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai" - + "&allowPublicKeyRetrieval=true", - "spring.jpa.hibernate.ddl-auto=none" -}) -@DisplayName("T9 压测补探针:请求级取树次数(P44)+ 含守卫形状下的 V10 延迟") -class PerfTreeCallsProbe { - - private static final long SCH = 990600L; - private static final int MANAGER_ID = 900001; - - @Autowired - private JdbcTemplate jdbc; - @Autowired - private FormDefService defService; - @Autowired - private FormAnswerReadController controller; - @Autowired - private CountingModuleTreeSource moduleTree; - - static class CountingModuleTreeSource extends InMemoryModuleTreeSource { - final AtomicInteger calls = new AtomicInteger(); - - @Override - public List authorizedModules(BoaUser user) { - calls.incrementAndGet(); - return super.authorizedModules(user); - } - } - - @TestConfiguration - static class ProbeGatewayConfiguration { - @Bean - @Primary - CountingModuleTreeSource countingModuleTreeSource() { - return new CountingModuleTreeSource(); - } - - @Bean - @Primary - InMemorySubjectDirectory probeSubjectDirectory() { - return new InMemorySubjectDirectory(); - } - } - - private final List report = new ArrayList<>(); - - @Test - @DisplayName("P44:列表/详情/统计/筛选候选/导出 各自一次请求取几棵树;同时给含守卫的请求级延迟") - void requestShape() throws Exception { - BoaUser manager = manager(); - String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f01'", - String.class); - String sampleAnswer = jdbc.queryForObject("select uuid from answer where sch_id=" + SCH - + " and code='perf_f01' and is_deleted=0 order by id desc limit 1", String.class); - - ReadModels.ListRequest list = new ReadModels.ListRequest(); - list.setFormUuid(uuid); - list.setSize(20); - ReadModels.ListRequest filtered = new ReadModels.ListRequest(); - filtered.setFormUuid(uuid); - filtered.setSize(20); - filtered.setFilters(Arrays.asList(eq("grade", "g2"), range("score", "30", "80"))); - ReadModels.QueryRequest stats = new ReadModels.QueryRequest(); - stats.setFormUuid(uuid); - stats.setGroupByField("grade"); - - log("P44 请求级取树次数(控制器形状,含 requireAdmin):列表=" - + probe(() -> controller.list(manager, null, list)) - + " 详情=" + probe(() -> controller.detail(manager, null, sampleAnswer)) - + " 统计=" + probe(() -> controller.stats(manager, null, stats)) - + " 筛选候选=" + probe(() -> controller.filterFields(manager, null, uuid)) - + " 导出=" + probe(() -> exportThroughController(manager, stats))); - - // 请求级延迟(比服务层多一次守卫取树;桩里取树是内存操作,真 UserBase 是签名 HTTP 调用) - sample("V10 请求级 列表 首屏", 40, () -> controller.list(manager, null, list)); - sample("V10 请求级 三条件筛选", 40, () -> controller.list(manager, null, filtered)); - sample("V10 请求级 统计(缓存命中态)", 20, () -> controller.stats(manager, null, stats)); - long start = System.nanoTime(); - int bytes = exportThroughController(manager, stats); - log("V10 请求级 导出(2 万级,写满 MockHttpServletResponse 缓冲区):字节=" + bytes - + " 耗时(ms)=" + (System.nanoTime() - start) / 1_000_000L + " 阈值=20000"); - - p36PublishTiming(); - Files.write(Paths.get(System.getProperty("user.dir"), "..", "local-debug", "perf-20261015", - "perf-request-shape.txt"), report, StandardCharsets.UTF_8); - } - - private int exportThroughController(BoaUser manager, ReadModels.QueryRequest body) { - try { - MockHttpServletResponse response = new MockHttpServletResponse(); - controller.export(manager, null, body, response); - return response.getContentAsByteArray().length; - } catch (Exception e) { - throw new IllegalStateException("导出端点调用失败:" + e.getMessage(), e); - } - } - - /** - * P36 的实测项:升版发布时同步补建历史索引行({@code backfillAfterPublish})在 2 万行表单上到底多久。 - * P36 记档的估计是"2 万级秒级",而 T9 的处置线写的是"若实测不可接受,改点在 FormDefService.publish 一处"—— - * 所以要量的是 publish 这一步本身,不是整段流程。 - */ - private void p36PublishTiming() { - String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f02'", - String.class); - Integer current = jdbc.queryForObject("select current_version from form where uuid='" + uuid + "'", - Integer.class); - String schema = jdbc.queryForObject("select schema_json from form_version where form_id=" - + "(select id from form where uuid='" + uuid + "') and version=" + current, String.class); - com.yunhai.formcenter.model.FormModels.FormSaveRequest draft = - new com.yunhai.formcenter.model.FormModels.FormSaveRequest(); - draft.setUuid(uuid); - draft.setSchemaJson(schema.replace("{\"key\":\"remark\"", - "{\"key\":\"p36_extra\",\"label\":\"P36 计时字段\",\"type\":\"text\",\"searchable\":true}," - + "{\"key\":\"remark\"")); - long saveStart = System.nanoTime(); - defService.save(SCH, draft); - long saveMs = (System.nanoTime() - saveStart) / 1_000_000L; - long publishStart = System.nanoTime(); - defService.publish(SCH, uuid, "P36 升版补建索引计时(新增 searchable 字段)"); - long publishMs = (System.nanoTime() - publishStart) / 1_000_000L; - Integer indexed = jdbc.queryForObject("select count(*) from answer_index where sch_id=" + SCH - + " and code='perf_f02' and field_key='p36_extra'", Integer.class); - long live = jdbc.queryForObject("select count(*) from answer where sch_id=" + SCH - + " and code='perf_f02' and is_deleted=0", Long.class); - log("P36 升版补建实测:草稿保存=" + saveMs + "ms;**发布=" + publishMs + "ms**(含同步补建索引);" - + "本表未删答卷=" + live + " 行,新字段索引行补出=" + indexed + " 行" - + "(P36 记档的\"2 万级秒级\"与实测的差值见 T9 报告 §4.5)"); - } - - private int probe(Runnable body) { - int before = moduleTree.calls.get(); - body.run(); - return moduleTree.calls.get() - before; - } - - private void sample(String label, int times, Runnable body) { - long coldStart = System.nanoTime(); - body.run(); - long cold = (System.nanoTime() - coldStart) / 1_000_000L; - List ms = new ArrayList<>(); - for (int i = 0; i < times; i++) { - long start = System.nanoTime(); - body.run(); - ms.add((System.nanoTime() - start) / 1_000_000L); - } - Collections.sort(ms); - log(label + " | n=" + ms.size() + " 冷启=" + cold + "ms p50=" + ms.get(ms.size() / 2) + "ms p95=" - + ms.get(Math.min(ms.size() - 1, (int) Math.ceil(ms.size() * 0.95) - 1)) + "ms max=" - + ms.get(ms.size() - 1) + "ms"); - } - - private BoaUser manager() { - BoaUser user = new BoaUser(); - user.setId(MANAGER_ID); - user.setType(2); - user.setRoleIds(Collections.singletonList(7)); - BoaRoleInfo current = new BoaRoleInfo(); - current.setRoleId(7); - current.setSchId((int) SCH); - user.setCurrentRole(current); - moduleTree.grantManageDefaults(MANAGER_ID); - return user; - } - - private static ReadModels.FilterItem eq(String key, String value) { - ReadModels.FilterItem item = new ReadModels.FilterItem(); - item.setKey(key); - item.setValue(value); - return item; - } - - private static ReadModels.FilterItem range(String key, String from, String to) { - ReadModels.FilterItem item = new ReadModels.FilterItem(); - item.setKey(key); - item.setFrom(from); - item.setTo(to); - return item; - } - - private void log(String line) { - report.add(line); - System.out.println("[PERF] " + line); - } -}