diff --git a/sql/h2_userbase_app_and_modules.sql b/sql/h2_userbase_app_and_modules.sql index d4b4e86..e593b1a 100644 --- a/sql/h2_userbase_app_and_modules.sql +++ b/sql/h2_userbase_app_and_modules.sql @@ -1,9 +1,10 @@ -- ============================================================================ -- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本 --- 编制:Qoder(T9 联调轮)|日期:2026-10-15 --- 依据:《表单中心执行手册》§2.2(中心应用六条模块行表 + 验证口径)、 --- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.4(customId=fc:scope:all)、 --- §6.5(中心=BUSINESS 应用,模块行与快应用模块行两棵树不混) +-- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役) +-- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、 +-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.5(中心=BUSINESS 应用, +-- 模块行与快应用模块行两棵树不混) +-- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役) -- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法) -- -- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。 @@ -37,14 +38,17 @@ SET @operator = 0; -- create_user 占位(0=系统) -- WHERE @center_app_id = 0; -- ---------------------------------------------------------------------------- --- 步骤 1:中心应用六条模块行(执行手册 §2.2 表;path 必须逐字等于 front/src/router/routes.ts) +-- 步骤 1:中心应用五条模块行(执行手册 §2.2 v1.5 表;path 必须逐字等于 front/src/router/routes.ts) -- ①表单设计 /admin/forms ②数据管理 /admin/data ③统计 /admin/stats --- ④字典管理 /admin/dicts ⑤我的填写 /my(全员) ⑥数据全量【path 留空 + customId=fc:scope:all】 --- 第⑥行=纯权限锚点:导航不渲染、后端 DataScopeService 只扫 path 空节点的 customId(P47 收紧后口径), --- 授权给哪个组,该组的数据范围即升级为"全部"(方案 §6.4)。 --- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail) +-- ④应用设置 /admin/app(页内 tab:角色设置/字典管理,字典 API 摊这一行把关) ⑤我的填写 /my(全员) +-- v1.5 改版(T10,2026-10-15 用户裁决): +-- · 原「字典管理 /admin/dicts」独立行撤销,迁入应用设置页内 tab(不占 modId、不进导航); +-- · 原「数据全量」path 空锚点行撤销——UserBase 侧 customId 数据范围协议退役, +-- 升级载体改为中心自建的管理员两表(school_admin / form_admin,见《管理员体系方案》§4/§5.1)。 +-- **中心应用自此不再有 path 空行**;前端 menuNav 的"path 空跳过"规则保留给快应用面的按钮锚点。 +-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail、/admin/app 的页内 tab) -- **不建模块行**(手册 §2.2 注记 + P63/P77):不占 modId、不进导航,前端按 meta.parentPath 归位。 --- custom_id 用 200~205 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId) +-- custom_id 用 200~204 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId) -- ---------------------------------------------------------------------------- INSERT INTO app_module (app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) @@ -53,10 +57,8 @@ SELECT * FROM ( '/admin/forms' AS path, 'word' AS icon, 1 AS level, 200 AS sort_no, @operator AS create_user, NOW() AS create_time UNION ALL SELECT @center_app_id, NULL, '数据管理', '201', '/admin/data', 'category', 1, 201, @operator, NOW() UNION ALL SELECT @center_app_id, NULL, '统计', '202', '/admin/stats','tongji', 1, 202, @operator, NOW() - UNION ALL SELECT @center_app_id, NULL, '字典管理', '203', '/admin/dicts','zidingyi', 1, 203, @operator, NOW() + UNION ALL SELECT @center_app_id, NULL, '应用设置', '203', '/admin/app', 'zidingyi', 1, 203, @operator, NOW() UNION ALL SELECT @center_app_id, NULL, '我的填写', '204', '/my', '', 1, 204, @operator, NOW() - -- 锚点行:path 空 + customId 协议值(非数字 customId 是刻意的,前端 modId 会回退主键 id,不影响消费) - UNION ALL SELECT @center_app_id, NULL, '数据全量', 'fc:scope:all', '', '', 1, 205, @operator, NOW() ) t WHERE @center_app_id > 0 AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id); @@ -124,12 +126,13 @@ SELECT g.group_id, a.app_id, @operator, NOW() WHERE NOT EXISTS (SELECT 1 FROM sys_group_app_rel r WHERE r.group_id = a.group_id AND r.app_id = a.app_id); --- 3.3 中心模块授权:管理员组=前五行(除锚点),全员组=只"我的填写", --- 要看全校数据的组=额外勾"数据全量"锚点行(customId=fc:scope:all) +-- 3.3 中心模块授权:管理员组=四个管理行(表单设计/数据管理/统计/应用设置),全员组=只"我的填写"。 +-- v1.5 起**没有"要看全校数据就多勾一行"这回事**:数据范围升级改由中心的管理员两表给 +-- (/admin/app 的角色设置 tab 加校级表单管理员,或表单列表/设计器给单表单管理员)。 INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) SELECT @grp_admin, m.id, 0, @operator, NOW() FROM app_module m - WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND COALESCE(m.custom_id,'') <> 'fc:scope:all' + WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.path <> '/my' AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id); INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) @@ -138,13 +141,6 @@ SELECT @grp_all, m.id, 0, @operator, NOW() WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my' AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id); --- 数据全量锚点(V42 的授权动作;只给确实要看全校数据的组) -INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) -SELECT @grp_admin, m.id, 0, @operator, NOW() - FROM app_module m - WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.custom_id = 'fc:scope:all' - AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id); - -- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行 INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) SELECT @grp_teacher, m.id, 0, @operator, NOW() @@ -172,21 +168,26 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW() -- ---------------------------------------------------------------------------- -- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行) --- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项,锚点行不出现) +-- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项) -- ②无授权用户:只见"我的填写" -- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树 -- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通; -- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。 --- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(锚点判定生效,P76) +-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76) -- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报 +-- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理), +-- 角色设置里加的人落 school_admin(数据范围升级,不是菜单行)——T10 V43/V46 -- ---------------------------------------------------------------------------- -SELECT '中心模块行数(期望 6)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id +SELECT '中心模块行数(期望 5)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id UNION ALL SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id UNION ALL -SELECT '中心 path 空锚点数(期望 1)', COUNT(*) FROM app_module +SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module WHERE app_id = @center_app_id AND (path IS NULL OR path = '') UNION ALL +SELECT '快应用 path 空锚点数(期望 2=导入/导出)', COUNT(*) FROM app_module + WHERE app_id = @quick_app_id AND (path IS NULL OR path = '') +UNION ALL SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id UNION ALL SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p diff --git a/sql/t10_admin_domain.sql b/sql/t10_admin_domain.sql new file mode 100644 index 0000000..eabf572 --- /dev/null +++ b/sql/t10_admin_domain.sql @@ -0,0 +1,64 @@ +-- ============================================================ +-- 表单中心 T10 · 管理员体系 DDL(MySQL 5.7 兼容复核版) +-- 范围:《管理员体系方案-20261015》§4 三笔 = school_admin / form_admin / answer 加列+加索引 +-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate +-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3 先例)。 +-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE; +-- ③InnoDB、无物理外键(方案 §7.7 逻辑外键约定:user_id→UserBase、form_uuid→form.uuid 都不建 FK); +-- ④可空列一律写 DEFAULT NULL,TEXT 类不出现(本脚本无大字段)。 +-- P5/P15 裁决:实体继承 BaseEntity → 实表=方案 §4 草案 + create_by/update_by 两审计列; +-- 审计列在基类未标 nullable=false,故按建表实态写 DEFAULT NULL(FormCenterAuditListener 恒写 now())。 +-- 重跑口径:MySQL 5.7 无 ADD COLUMN IF NOT EXISTS,本脚本【不是幂等的】——执行前先用 +-- `SHOW COLUMNS FROM answer LIKE 'submitter_sch_id'` 确认列不存在;两表若已建则只跑第三条 ALTER。 +-- 存量:生产零存量(H2 尚未执行、160 在线仍是 T5 旧版),无需回填;本地/perf 库的测试数据不强推 +-- (NULL 行天然不匹配 SCHOOL 谓词=fail-closed,方案 §4 存量条款)。 +-- 部署说明已同步补"加列"一笔(《表单中心160部署Runbook》§0)。 +-- ============================================================ + +-- ① 校级表单管理员:数据范围扩展载体,取代已退役的 UserBase 侧 customId 权限锚点行(方案 §4①/§7) +CREATE TABLE `school_admin` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '租户键(=会话校;超管代查时=显式目标校)', + `user_id` INT NOT NULL COMMENT 'UserBase 用户主键(BoaUser.id),逻辑外键不建 FK', + `user_name` VARCHAR(64) NOT NULL COMMENT '姓名快照(列表展示免回查,权威在 UserBase)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_school_admin` (`sch_id`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='校级表单管理员(方案 §5.1:本校表单范围=全部,基础表单范围=本校提交)'; + +-- ② 单表单管理员:指定表单的数据查看+导出(D3 裁决:不含编辑权) +CREATE TABLE `form_admin` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `form_uuid` VARCHAR(32) NOT NULL COMMENT '授权表单 uuid(跨版本不变,契约 §1)', + `sch_id` BIGINT NOT NULL COMMENT '冗余表单租户域(form.sch_id,基础表单=0),落库域复核用', + `user_id` INT NOT NULL, + `user_name` VARCHAR(64) NOT NULL COMMENT '姓名快照(列表展示免回查,权威在 UserBase)', + `create_by` VARCHAR(255) DEFAULT NULL, + `create_time` DATETIME DEFAULT NULL, + `update_by` VARCHAR(255) DEFAULT NULL, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_form_admin` (`form_uuid`, `user_id`), + KEY `idx_form_admin_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='单表单管理员(方案 §5.1:指定表单=全部,基础表单上=本校提交)'; + +-- ③ 答卷加列:填报人会话学校(D2=B 的落点,SCHOOL 范围的谓词列) +-- NULL=未记录(存量行),按"不参与校级范围匹配"处理;updatable=false(落值后不随改) +ALTER TABLE `answer` + ADD COLUMN `submitter_sch_id` BIGINT NULL DEFAULT NULL + COMMENT '填报人会话学校(提交时从会话 currentRole.schId 落值;NULL=未记录,不参与校级范围匹配)' + AFTER `submitter_id`; + +ALTER TABLE `answer` ADD KEY `idx_answer_submitter_sch` (`submitter_sch_id`); + +-- 自验(执行后逐条核对,V47): +-- SHOW CREATE TABLE answer\G -- 应含 submitter_sch_id bigint NULL + KEY idx_answer_submitter_sch +-- EXPLAIN SELECT id FROM answer WHERE sch_id=3260 AND app_code='form-center' AND code='x' +-- AND is_deleted=0 AND submitter_sch_id=3260; -- SCHOOL 谓词应能走 key 列(V47 探针口径) +-- SELECT COUNT(*) FROM school_admin; -- 期望 0(新库) +-- SELECT COUNT(*) FROM form_admin; -- 期望 0(新库) diff --git a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java index 45527f9..0fbb738 100644 --- a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java +++ b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java @@ -7,14 +7,14 @@ import java.util.List; import java.util.Set; /** - * 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。 + * 授权模块树的权限键扁平化(方案 §6.1 第②层)。 * - *

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

- * + *

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

+ * + *

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

* *

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

*/ @@ -23,31 +23,17 @@ public final class FormModuleKeys { private FormModuleKeys() { } - /** 授权页面路由集合(path 空的权限锚点不在内) */ + /** 授权页面路由集合(path 空的权限位不在内) */ public static Set paths(List tree) { Set result = new LinkedHashSet<>(); - collect(tree, true, result); + collect(tree, result); return result; } /** - * 权限锚点的 customId 集合:只收 path 为空的节点(契约 §8「锚点行形态」注记 / P47)。 - * - *

数据范围升级位({@code fc:scope:all})按契约只应挂在不进导航的 path 空行上, - * 扫描必须按这个形态收:带 path 的导航行若误配了该值,不该把看数据的人悄悄升级成"全校全部" - * ——宽方向的误配同样是不受控的授权膨胀。

+ * 递归展平。取不出 path 的节点自然被跳过,子节点照常下钻(页面行可以挂在分组层下面)。 */ - public static Set anchorCustomIds(List tree) { - Set result = new LinkedHashSet<>(); - collect(tree, false, result); - return result; - } - - /** - * 递归展平(wantPath=true 取 path;false 取「path 空节点」的 customId)。 - * 取不出值的节点自然被跳过,子节点照常下钻(锚点可以挂在分组层下面)。 - */ - private static void collect(List nodes, boolean wantPath, Set out) { + private static void collect(List nodes, Set out) { if (nodes == null) { return; } @@ -56,11 +42,10 @@ public final class FormModuleKeys { continue; } String path = trimToNull(node.getPath()); - String value = wantPath ? path : (path == null ? trimToNull(node.getCustomId()) : null); - if (value != null) { - out.add(value); + if (path != null) { + out.add(path); } - collect(node.getChildren(), wantPath, out); + collect(node.getChildren(), out); } } diff --git a/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java b/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java new file mode 100644 index 0000000..9328baa --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java @@ -0,0 +1,102 @@ +package com.yunhai.formcenter.controller.api; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.model.AdminModels; +import com.yunhai.formcenter.service.AdminDomainService; +import com.yunhai.formcenter.service.FormAdminGuard; +import com.yunhai.formcenter.service.FormDefService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 管理员两表的维护端点(T10 方案 §5.3):校级表单管理员三端点摊 {@code /admin/app}(应用设置行), + * 单表单管理员三端点摊 {@code /admin/forms}(表单设计行)。 + * + *

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

+ * + *

返回形态:写操作直接回操作后的全量列表(不分页,每校/每表单个位数), + * 前端省一次刷新请求,也就不会出现"删了最后一员、列表还留着那行"的中间态。

+ */ +@RestController +@RequestMapping("/api/form/admin") +@RequiredArgsConstructor +public class AdminDomainController { + + private final FormAdminGuard adminGuard; + private final FormDefService defService; + private final AdminDomainService adminService; + + // ==================== 校级表单管理员 ==================== + + /** 全量列表(超管可用 {@code schId} 代查任意校) */ + @GetMapping("/school-admins") + public List listSchoolAdmins(BoaUser user, + @RequestParam(required = false) Long schId) { + return adminService.listSchoolAdmins( + adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS)); + } + + @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); + return adminService.addSchoolAdmins(domain, body == null ? null : body.getUsers()); + } + + @DeleteMapping("/school-admins/{id}") + public List removeSchoolAdmin(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("id") Long id) { + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS); + return adminService.removeSchoolAdmin(domain, id); + } + + // ==================== 单表单管理员 ==================== + + /** 表单定位走数据面同一条 {@code readDomainForm}:本校 ∪ 平台级可见,他校按"表单不存在"回答 */ + @GetMapping("/forms/{formUuid}/admins") + public List listFormAdmins(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("formUuid") String formUuid) { + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN); + return adminService.listFormAdmins(requireForm(domain, formUuid)); + } + + @PostMapping("/forms/{formUuid}/admins") + public List addFormAdmins(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("formUuid") String formUuid, + @RequestBody AdminModels.AdminAddRequest body) { + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN); + Form form = requireForm(domain, formUuid); + return adminService.addFormAdmins(form, domain, body == null ? null : body.getUsers()); + } + + @DeleteMapping("/forms/{formUuid}/admins/{id}") + public List removeFormAdmin(BoaUser user, + @RequestParam(required = false) Long schId, + @PathVariable("formUuid") String formUuid, + @PathVariable("id") Long id) { + Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN); + Form form = requireForm(domain, formUuid); + return adminService.removeFormAdmin(form, id); + } + + private Form requireForm(Long domain, String formUuid) { + return defService.readDomainForm(domain, formUuid); + } +} 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 165a166..d40e4e7 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java @@ -156,41 +156,41 @@ public class FormAdminController { return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); } - // ==================== 选项字典(契约 §2.1) ==================== + // ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ==================== /** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */ @GetMapping("/dict/list") public List dictList(BoaUser user, @RequestParam(required = false) Long schId) { - return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT)); + return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS)); } /** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */ @GetMapping("/dict/selectable") public List dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) { - return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT)); + return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS)); } @PostMapping("/dict/save") public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { - return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode(), body.getName()); + return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), 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_DICT), body.getCode()); + dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), 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_DICT), + return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), 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_DICT), body.getDictCode(), body.getValue()); + dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getDictCode(), body.getValue()); } private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) { 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 d8a7932..ecb0437 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java @@ -6,6 +6,7 @@ import com.yunhai.formcenter.service.AnswerReadService; import com.yunhai.formcenter.service.AnswerStatsService; import com.yunhai.formcenter.service.DataScopeService; import com.yunhai.formcenter.service.FormAdminGuard; +import com.yunhai.formcenter.service.FormDefService; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; @@ -25,13 +26,14 @@ import java.net.URLEncoder; import java.util.List; /** - * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。 + * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。 * *

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

*
    - *
  • 能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data}, - * 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");
  • - *
  • 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId, + *
  • 能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data} + * 或统计 {@code /admin/stats} 任一,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面; + * 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);
  • + *
  • 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1; * 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。
  • *
* @@ -49,6 +51,7 @@ import java.util.List; public class FormAnswerReadController { private final FormAdminGuard adminGuard; + private final FormDefService defService; private final AnswerReadService readService; private final AnswerStatsService statsService; private final AnswerExportService exportService; @@ -59,7 +62,7 @@ public class FormAnswerReadController { @RequestParam(required = false) Long schId, @RequestBody ReadModels.ListRequest body) { requireFormUuid(body.getFormUuid()); - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid())); return readService.list(user, domain, body); } @@ -68,7 +71,10 @@ public class FormAnswerReadController { public ReadModels.DetailView detail(BoaUser user, @RequestParam(required = false) Long schId, @RequestParam("answerId") String answerId) { - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + // 入口时只有答卷 uuid、表单还没定位,故按"数据管理或统计任一"取域; + // 域内可见性由 readService.detail 里的同一份范围规格 + visible() 行级把关(与列表同集,V27) + Long domain = adminGuard.requireAdminAny(user, schId, + FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE); return readService.detail(user, domain, answerId); } @@ -78,7 +84,7 @@ public class FormAnswerReadController { @RequestParam(required = false) Long schId, @RequestParam("formUuid") String formUuid) { requireFormUuid(formUuid); - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(formUuid)); return readService.filterFields(domain, formUuid); } @@ -88,8 +94,7 @@ public class FormAnswerReadController { @RequestParam(required = false) Long schId, @RequestBody ReadModels.QueryRequest body) { requireFormUuid(body.getFormUuid()); - Long domain = adminGuard.requireAdminAny(user, schId, - FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid())); return statsService.stats(user, domain, body); } @@ -106,7 +111,7 @@ public class FormAnswerReadController { @RequestBody ReadModels.QueryRequest body, HttpServletResponse response) throws IOException { requireFormUuid(body.getFormUuid()); - Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE); + Long domain = adminGuard.requireDataView(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 5f8d25e..84555aa 100644 --- a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java +++ b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java @@ -1,8 +1,12 @@ 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; @@ -29,11 +33,15 @@ import java.util.Map; */ @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 静态客户端。 @@ -48,6 +56,21 @@ public class FormBaseController { return authUserWithDept; } + /** + * 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。 + * + *

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

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

依赖 UserBase 侧数据:本应用的 {@code app_module} 行({@code path} 须逐字对上 @@ -110,8 +133,8 @@ public class FormBaseController { * modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的 * {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。 * 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。 - * 表单中心的 {@code fc:scope:all}(数据范围权限锚点,契约 §8)是非数字 customId, - * 且 path 为空、不进导航,故只由后端扫描授权集合消费,不依赖本映射结果。 + * 中心应用的模块行自 T10(手册 §2.2 v1.5 五行版)起 customId 恒为数字 200~204;非数字时回退主键 id + * 只是防御性兜底,不再有"数据范围权限锚点"依赖这条映射(该协议已于 T10 退役,载体换成管理员两表)。 */ private static Object resolveModId(BoaModule m) { String customId = m.getCustomId(); diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java index f420952..462b163 100644 --- a/src/main/java/com/yunhai/formcenter/entity/Answer.java +++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java @@ -39,6 +39,8 @@ import javax.persistence.UniqueConstraint; @Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"), @Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"), @Index(name = "idx_answer_subject", columnList = "subject_id"), + // T10 方案 §4③:SCHOOL 范围(本校提交)的谓词列索引 + @Index(name = "idx_answer_submitter_sch", columnList = "submitter_sch_id"), // 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id) // ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。 // 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的, @@ -82,6 +84,17 @@ public class Answer extends BaseEntity { @Column(name = "submitter_id", nullable = false, updatable = false) private Integer submitterId; + /** + * 填报人会话学校(T10 方案 §4③,D2=B 的落点):范围值 {@code SCHOOL} 的谓词列, + * 提交时从会话 {@code currentRole.schId} 落值、之后不改。 + * + *

NULL=未记录(存量行),按"不参与校级范围匹配"处理(fail-closed,方案 §4 存量条款)。 + * 它与 {@code sch_id} 同源于会话校(校域不变式:见下方建表注记),本列是把"本校提交" + * 写成显式谓词,而不是靠租户域隐含它。

+ */ + @Column(name = "submitter_sch_id", updatable = false) + private Long submitterSchId; + /** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */ @Column(name = "subject_id") private Integer subjectId; diff --git a/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java new file mode 100644 index 0000000..6a95292 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java @@ -0,0 +1,44 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的数据查看 + 导出授权(D3 裁决, + * 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。 + * + *

{@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与 + * 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为 + * {@code SCHOOL}(跨校学生数据不因授权而可见,D2 裁决)。

+ */ +@Getter +@Setter +@Entity +@Table(name = "form_admin", + uniqueConstraints = @UniqueConstraint(name = "uk_form_admin", columnNames = {"form_uuid", "user_id"}), + indexes = @Index(name = "idx_form_admin_user", columnList = "user_id")) +public class FormAdmin extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 授权的表单(按 uuid,跨版本不变——契约 §1) */ + @Column(name = "form_uuid", nullable = false, length = 32, updatable = false) + private String formUuid; + + /** 冗余表单租户域(form.sch_id,基础表单=0) */ + @Column(name = "sch_id", nullable = false, updatable = false) + private Long schId; + + @Column(name = "user_id", nullable = false, updatable = false) + private Integer userId; + + /** 姓名快照,仅供列表展示 */ + @Column(name = "user_name", nullable = false, length = 64) + private String userName; +} diff --git a/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java new file mode 100644 index 0000000..c484bfe --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java @@ -0,0 +1,42 @@ +package com.yunhai.formcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 校级表单管理员(T10 / 管理员体系方案 §4①):数据范围扩展的载体,取代已退役的 UserBase 侧 + * customId 权限锚点行(方案 §7)。 + * + *

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

+ * + *

{@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权 + * ——解析入口是会话,会话失效即判不到(方案 §8)。

+ */ +@Getter +@Setter +@Entity +@Table(name = "school_admin", + uniqueConstraints = @UniqueConstraint(name = "uk_school_admin", columnNames = {"sch_id", "user_id"})) +public class SchoolAdmin extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 租户键(= 会话校;超管代查时=显式指定的目标校) */ + @Column(name = "sch_id", nullable = false, updatable = false) + private Long schId; + + /** UserBase 用户主键({@code BoaUser.id}) */ + @Column(name = "user_id", nullable = false, updatable = false) + private Integer userId; + + /** 姓名快照,仅供列表展示 */ + @Column(name = "user_name", nullable = false, length = 64) + private String userName; +} diff --git a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java index ee698cd..f86ad30 100644 --- a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java +++ b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java @@ -101,17 +101,22 @@ public final class FormEnums { } /** - * 数据范围(契约 §8 / 方案 §6.4):闭集四枚举,一期契约即含全部四值—— - * 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。 + * 数据范围(契约 §8 / 方案 §6.4):一期契约的四值闭集 + T10 的 {@link #SCHOOL}—— + * "定了就不变"指既不删改既有值、也不改既有语义,扩展走"加枚举=加实现器"这条既定通道 + * (管理员体系方案 §5.1)。 * *

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

+ * + *

{@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径): + * 它不是筛选器选项,而是管理员两表授权后的解析结果(谓词 {@code submitter_sch_id}=会话学校)。

*/ public enum DataScope { ALL("全部"), TEACHING_CLASS("任教班级"), SELF_SUBMITTED("仅本人提交"), - ABOUT_ME("关于我"); + ABOUT_ME("关于我"), + SCHOOL("本校提交"); private final String label; diff --git a/src/main/java/com/yunhai/formcenter/model/AdminModels.java b/src/main/java/com/yunhai/formcenter/model/AdminModels.java new file mode 100644 index 0000000..51c986b --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/AdminModels.java @@ -0,0 +1,45 @@ +package com.yunhai.formcenter.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 管理员两表的出入参模型(T10 方案 §5.3,中心 {@code /api} 面、信封 code=0 族)。 + * + *

与契约 §1 的"库内 id 不外露"唯一有出入的地方是 {@link AdminView#getId()}: + * 方案 §5.3 的删除端点形态就是 {@code DELETE /…/{id}}(管理员行没有 uuid 可用), + * 该 id 只在本人的管理面内出现、且删除前还要按 {@code (id, 会话域)} 复核归属, + * 不构成答卷数据面那种可枚举资源标识。取舍记在 T10 报告。

+ */ +public final class AdminModels { + + private AdminModels() { + } + + /** 选人弹窗提交的一个候选人(userId=UserBase 主键;userName 只做展示快照) */ + @Getter + @Setter + public static class AdminUserItem { + private Integer userId; + private String userName; + } + + /** 批量添加请求体:{@code schId} 只是超管的目标域,校级传别的值即拒(同管理面口径) */ + @Getter + @Setter + public static class AdminAddRequest { + private Long schId; + private List users; + } + + /** 列表行(每校/每表单个位数,一律不分页,方案 §4) */ + @Getter + @Setter + public static class AdminView { + private Long id; + private Integer userId; + private String userName; + } +} diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java index 67814eb..0c60dc7 100644 --- a/src/main/java/com/yunhai/formcenter/model/ReadModels.java +++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java @@ -178,6 +178,8 @@ public final class ReadModels { private String code; private Set scopes; private Integer userId; + /** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */ + private Long viewerSchId; private String status; private List filters; /** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */ diff --git a/src/main/java/com/yunhai/formcenter/model/SchoolModels.java b/src/main/java/com/yunhai/formcenter/model/SchoolModels.java new file mode 100644 index 0000000..98ae367 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/model/SchoolModels.java @@ -0,0 +1,30 @@ +package com.yunhai.formcenter.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 本校组织树(选人弹窗数据源)的下发形状(T10 方案 §5.3 末段:同 notice 形态,天然限本校树)。 + * + *

字段名对齐前端移植自 notice 的 {@code UserCheckModal.vue}:它按 {@code isParent} 判组/人 + * (组=文件夹、叶子=人员),按 {@code children} 递归,{@code id/name} 直接进树。

+ */ +public final class SchoolModels { + + private SchoolModels() { + } + + @Getter + @Setter + public static class Node { + /** 组节点=UserBase 组 id;人节点=UserBase 用户主键(即 admin 两表的 user_id) */ + private Integer id; + private String name; + /** true=组(可展开),false=人员叶子 */ + private Boolean isParent; + private List children = new ArrayList<>(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java new file mode 100644 index 0000000..edaadc8 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.FormAdmin; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * 单表单管理员仓库(T10)。规模=每表单个位数,列表一律不分页(方案 §4)。 + * + *

{@code uk_form_admin(form_uuid, user_id)} 是重复添加的幂等兜底;{@code idx_form_admin_user} + * 供"我管理的表单"这类按人的反查(v2 入口,本轮不消费,方案 §8 已登记)。

+ */ +public interface FormAdminRepository extends JpaRepository { + + List findByFormUuidOrderByIdAsc(String formUuid); + + Optional findByIdAndFormUuid(Long id, String formUuid); + + boolean existsByFormUuidAndUserId(String formUuid, Integer userId); +} diff --git a/src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java b/src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java new file mode 100644 index 0000000..57a2425 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java @@ -0,0 +1,24 @@ +package com.yunhai.formcenter.repository; + +import com.yunhai.formcenter.entity.SchoolAdmin; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * 校级表单管理员仓库(T10)。规模=每校个位数,列表一律不分页(方案 §4)。 + * + *

{@code uk_school_admin(sch_id, user_id)} 是重复添加的幂等兜底;行按 id 升序回显, + * 与前端"序号/姓名/操作"的列表形态同序。

+ */ +public interface SchoolAdminRepository extends JpaRepository { + + List findBySchIdOrderByIdAsc(Long schId); + + Optional findByIdAndSchId(Long id, Long schId); + + boolean existsBySchIdAndUserId(Long schId, Integer userId); + + long countBySchId(Long schId); +} diff --git a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java index 27dc31d..978231d 100644 --- a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java @@ -29,7 +29,7 @@ public class AboutMeScopeImplementor implements AnswerScopeImplementor { } @Override - public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) { + public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { return cb.equal(root.get("subjectId"), userId); } } diff --git a/src/main/java/com/yunhai/formcenter/service/AdminDomainService.java b/src/main/java/com/yunhai/formcenter/service/AdminDomainService.java new file mode 100644 index 0000000..f97f2cb --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AdminDomainService.java @@ -0,0 +1,191 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.entity.FormAdmin; +import com.yunhai.formcenter.entity.SchoolAdmin; +import com.yunhai.formcenter.model.AdminModels; +import com.yunhai.formcenter.repository.FormAdminRepository; +import com.yunhai.formcenter.repository.SchoolAdminRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * 管理员两表的读写(T10 方案 §4/§5.3):{@link AdminScopeSource} 的生产实现 + 管理面 CRUD。 + * + *

三条写侧纪律:

+ *
    + *
  1. 幂等:{@code uk_school_admin(sch_id,user_id)} / {@code uk_form_admin(form_uuid,user_id)} + * 是重复添加的兜底——已存在的行直接跳过,不报错也不重复建行(沿登记脚本的幂等哲学);
  2. + *
  3. 不信任前端:选人弹窗的名单来自 {@code GetSchoolAll()}(天然限本校树),但落库前仍逐个 + * 走 {@link SubjectDirectory#existsInSchool} 复核归属——传别人的 userId 一律拒(方案 §5.3 末段);
  4. + *
  5. 防呆:校级管理员删到剩 0 拒绝。没有校级管理员的学校仍能用矩阵默认口径读数据, + * 但"删空最后一员"会让此后无人能进角色设置页加人(D6 的自管理闭环断掉),所以宁可按业务规则拒。
  6. + *
+ * + *

行删除一律按 {@code (id, 本次操作域)} 定位({@code findByIdAndSchId}/{@code findByIdAndFormUuid}), + * 跨校/跨表单的 id 探测与"不存在"同一口径回答;{@code user_name} 是展示快照,权威始终在 UserBase。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminDomainService implements AdminScopeSource { + + /** 与 school_admin.user_name / form_admin.user_name 的列宽一致(超长按 PARAM_ERROR 拒,不给 500) */ + private static final int USER_NAME_MAX = 64; + + private final SchoolAdminRepository schoolAdminRepository; + private final FormAdminRepository formAdminRepository; + private final SubjectDirectory subjectDirectory; + + // ==================== 只读判定(AdminScopeSource) ==================== + + @Override + public boolean isSchoolAdmin(Long schId, Integer userId) { + return schId != null && userId != null && schoolAdminRepository.existsBySchIdAndUserId(schId, userId); + } + + @Override + public boolean isFormAdmin(String formUuid, Integer userId) { + if (formUuid == null || formUuid.trim().isEmpty() || userId == null) { + return false; + } + return formAdminRepository.existsByFormUuidAndUserId(formUuid.trim(), userId); + } + + // ==================== 校级表单管理员(守 /admin/app) ==================== + + public List listSchoolAdmins(Long schId) { + List views = new ArrayList<>(); + for (SchoolAdmin row : schoolAdminRepository.findBySchIdOrderByIdAsc(schId)) { + views.add(toView(row.getId(), row.getUserId(), row.getUserName())); + } + return views; + } + + /** 批量添加(幂等);返回添加后的全量列表,前端免去第二次请求 */ + @Transactional + public List addSchoolAdmins(Long schId, List users) { + for (AdminModels.AdminUserItem item : requireUsers(users)) { + requireBelongsToSchool(item, schId); + if (schoolAdminRepository.existsBySchIdAndUserId(schId, item.getUserId())) { + continue; + } + SchoolAdmin row = new SchoolAdmin(); + row.setSchId(schId); + row.setUserId(item.getUserId()); + row.setUserName(item.getUserName()); + // saveAndFlush:同批里重复的人(前端理论上不会给出,但接口不拒这种形态)也要被 exists 检查看到, + // 不能让 uk 撞键把一次正常的幂等添加变成 500 + schoolAdminRepository.saveAndFlush(row); + log.info("[角色设置] 新增校级表单管理员 schId={} userId={} name={}", + schId, item.getUserId(), item.getUserName()); + } + return listSchoolAdmins(schId); + } + + @Transactional + public List removeSchoolAdmin(Long schId, Long id) { + SchoolAdmin row = schoolAdminRepository.findByIdAndSchId(id, schId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "校级管理员记录不存在:" + id)); + if (schoolAdminRepository.countBySchId(schId) <= 1) { + throw new BizException(ErrorCode.CUSTOM, "本校至少保留一名校级表单管理员,最后一名不可删除"); + } + schoolAdminRepository.delete(row); + log.info("[角色设置] 删除校级表单管理员 schId={} userId={} rowId={}", schId, row.getUserId(), row.getId()); + return listSchoolAdmins(schId); + } + + // ==================== 单表单管理员(守 /admin/forms) ==================== + + public List listFormAdmins(Form form) { + List views = new ArrayList<>(); + for (FormAdmin row : formAdminRepository.findByFormUuidOrderByIdAsc(form.getUuid())) { + views.add(toView(row.getId(), row.getUserId(), row.getUserName())); + } + return views; + } + + /** + * 批量添加(幂等)。 + * + * @param domain 本次操作域(守卫返回的租户域)——基础表单的 {@code form.schId=0} 不是可复核的学校, + * 归属复核按 域 走:校级操作人=会话校(因此只能把自己学校的人加进基础表单的管理员), + * 超管平台态(域=0)在此被 {@link SubjectDirectory} 判为"不属于本校"而拒, + * 需要给基础表单授权时显式带 {@code schId=某校}。取舍记在 T10 报告。 + */ + @Transactional + public List addFormAdmins(Form form, Long domain, + List users) { + for (AdminModels.AdminUserItem item : requireUsers(users)) { + requireBelongsToSchool(item, domain); + if (formAdminRepository.existsByFormUuidAndUserId(form.getUuid(), item.getUserId())) { + continue; + } + FormAdmin row = new FormAdmin(); + row.setFormUuid(form.getUuid()); + row.setSchId(form.getSchId()); + row.setUserId(item.getUserId()); + row.setUserName(item.getUserName()); + formAdminRepository.saveAndFlush(row); + log.info("[表单管理员] 新增 form={} userId={} name={}", + form.getCode(), item.getUserId(), item.getUserName()); + } + return listFormAdmins(form); + } + + @Transactional + public List removeFormAdmin(Form form, Long id) { + FormAdmin row = formAdminRepository.findByIdAndFormUuid(id, form.getUuid()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单管理员记录不存在:" + id)); + formAdminRepository.delete(row); + log.info("[表单管理员] 删除 form={} userId={}", form.getCode(), row.getUserId()); + return listFormAdmins(form); + } + + // ==================== 内部 ==================== + + /** 请求形态校验:非空名单 + 每人有 userId、有姓名快照(列宽内) */ + private List requireUsers(List users) { + if (users == null || users.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一人(users 为空)"); + } + for (AdminModels.AdminUserItem item : users) { + if (item == null || item.getUserId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少 userId"); + } + String name = item.getUserName() == null ? null : item.getUserName().trim(); + if (name == null || name.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少姓名:userId=" + item.getUserId()); + } + if (name.length() > USER_NAME_MAX) { + throw new BizException(ErrorCode.PARAM_ERROR, + "姓名快照过长(≤" + USER_NAME_MAX + "):userId=" + item.getUserId()); + } + item.setUserName(name); + } + return users; + } + + /** 落库前的归属复核(方案 §5.3:不信任前端,跨校传参一律拒) */ + private void requireBelongsToSchool(AdminModels.AdminUserItem item, Long schId) { + if (!subjectDirectory.existsInSchool(item.getUserId(), schId)) { + throw new BizException(ErrorCode.FORBIDDEN, + "该用户不属于本次管理域,不能加为管理员:userId=" + item.getUserId() + " schId=" + schId); + } + } + + private static AdminModels.AdminView toView(Long id, Integer userId, String userName) { + AdminModels.AdminView view = new AdminModels.AdminView(); + view.setId(id); + view.setUserId(userId); + view.setUserName(userName); + return view; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java b/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java new file mode 100644 index 0000000..002bbc3 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java @@ -0,0 +1,21 @@ +package com.yunhai.formcenter.service; + +/** + * 管理员两表的只读判定端口(T10 方案 §5.1):范围解析与守卫都从这里问"这个人在不在 + * {@code school_admin} / {@code form_admin} 里",生产实现是 {@code AdminDomainService}(直查本库两表)。 + * + *

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

+ * + *

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

+ */ +public interface AdminScopeSource { + + /** 该用户是否 {@code schId} 的校级表单管理员 */ + boolean isSchoolAdmin(Long schId, Integer userId); + + /** 该用户是否这张表单(按 uuid)的单表单管理员 */ + boolean isFormAdmin(String formUuid, Integer userId); +} diff --git a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java index ece1ca3..e9c6ecd 100644 --- a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java @@ -8,7 +8,7 @@ import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; -/** 范围实现器:全部——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */ +/** 范围实现器:全部——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */ @Component public class AllScopeImplementor implements AnswerScopeImplementor { @@ -23,7 +23,7 @@ public class AllScopeImplementor implements AnswerScopeImplementor { } @Override - public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) { + public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { return cb.conjunction(); } } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java index a255f76..60accd4 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -72,7 +72,7 @@ public class AnswerReadService { /** * 会话身份 + 表单 + 请求 → 读路径查询规格。 * - *

范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来 + *

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

@@ -84,6 +84,8 @@ public class AnswerReadService { query.setCode(form.getCode()); query.setScopes(scopeService.resolve(user, form)); query.setUserId(user == null ? null : user.getId()); + // T10 方案 §5.1:SCHOOL 范围的谓词基准=会话学校(不是查询域,两者同源于会话校但语义不同) + query.setViewerSchId(user == null ? null : sessionSchId(user)); query.setStatus(req == null ? null : trimToNull(req.getStatus())); query.setFilters(resolveFilters(form, req == null ? null : req.getFilters())); return query; diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java index 45c4ba9..4cc23bd 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java @@ -31,8 +31,10 @@ public interface AnswerScopeImplementor { /** * 产出该范围对单条答卷的行级谓词。 * - * @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准) + * @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准) + * @param viewerSchId 会话学校({@code submitter_sch_id} 的比对基准,T10 方案 §5.1; + * 只有 {@code SCHOOL} 消费它,其余范围按各自基准忽略即可) * @return 恒真({@code cb.conjunction()})=该范围不额外收窄行 */ - Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId); + Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId); } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java index b4b5a2a..7ee07e3 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java @@ -180,6 +180,7 @@ public class AnswerScopeQueryBuilder { copy.setCode(q.getCode()); copy.setScopes(q.getScopes()); copy.setUserId(q.getUserId()); + copy.setViewerSchId(q.getViewerSchId()); copy.setStatus(q.getStatus()); copy.setFilters(q.getFilters()); copy.setCursorId(q.getCursorId()); @@ -234,7 +235,8 @@ public class AnswerScopeQueryBuilder { } List ors = new ArrayList<>(); for (DataScope scope : scopes) { - ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q.getUserId())); + ors.add(scopeService.implementorOf(scope) + .rowPredicate(cb, root, q.getUserId(), q.getViewerSchId())); } return cb.or(ors.toArray(new Predicate[0])); } diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index f5af0f9..92cbbb9 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -135,6 +135,9 @@ public class AnswerService { answer.setCode(form.getCode()); answer.setVersion(published.versionRow().getVersion()); answer.setSubmitterId(submitterId); + // T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证 + // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false) + answer.setSubmitterSchId(schId); answer.setSubjectId(subjectId); answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); answer.setValuesJson(valuesJson); diff --git a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java index 6332784..1c972f2 100644 --- a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java +++ b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java @@ -1,6 +1,5 @@ package com.yunhai.formcenter.service; -import com.yunhai.formcenter.common.FormModuleKeys; import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums.DataScope; @@ -19,12 +18,17 @@ import java.util.List; import java.util.Set; /** - * 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围, - * 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。 + * 数据范围解析器(契约 §8 / 方案 §6.4 / 管理员体系方案 §5.1):把"会话用户 + 这张表单"解析成一组 + * 可见范围,交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。 * - *

解析规则(优先级):{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵}, + *

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

* + *

管理员两表来源(T10 起,取代已退役的 {@code customId} 权限锚点行): + * {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级—— + * 本校表单=全部(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})= + * 本校提交(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。

+ * *

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

@@ -39,18 +43,16 @@ import java.util.Set; * 4/6/5 仅本人提交 仅本人提交 * * - *

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

+ *

与 {@link FormAdminGuard} 是两条正交的口径:守卫答"能不能进这个管理面"(UserBase 授权模块行, + * 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。两条线的事实源不同——模块行来自 UserBase + * ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面 + * 入口凭证(方案 §3 三层模型不变)。

*/ @Slf4j @Service @RequiredArgsConstructor public class DataScopeService { - /** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */ - public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all"; - /** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */ private static final int SUPER_ADMIN_TYPE = 1; @@ -59,13 +61,14 @@ public class DataScopeService { private static final int ROLE_STUDENT = 3; private static final int ROLE_ADMIN = 7; - private final ModuleTreeSource moduleTreeSource; private final List implementors; + private final AdminScopeSource adminScopeSource; /** * 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。 * - * @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理 + * @param form 目标表单({@code schId} 决定两表升级成哪一档、{@code audience} 决定用矩阵哪一列); + * null 按面向学生列解析,且不产生两表升级(无表单域可判) */ public Set resolve(BoaUser user, Form form) { if (user == null || user.getId() == null) { @@ -75,9 +78,10 @@ public class DataScopeService { return EnumSet.of(DataScope.ALL); } Set scopes = new LinkedHashSet<>(); - // 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认) - if (hasScopeAllAnchor(user)) { - scopes.add(DataScope.ALL); + // 1) 管理员两表授权(契约 §8 v1.5 优先级:管理员来源授权 > 身份默认) + DataScope granted = grantedByAdminTables(user, form); + if (granted != null) { + scopes.add(granted); } // 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽) int audience = form == null || form.getAudience() == null @@ -96,8 +100,8 @@ public class DataScopeService { } /** - * 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里, - * 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。 + * 一期可选的范围值(V28 的断言载体):{@code 任教班级} 的实现器 {@code selectable()=false}, + * T10 新增的 {@code SCHOOL} 同样 false——它不是筛选器选项,是授权结果(方案 §5.1 明示)。 */ public Set selectableScopes() { Set result = new LinkedHashSet<>(); @@ -119,9 +123,34 @@ public class DataScopeService { throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope); } - /** 是否命中 fc:scope:all 权限锚点(只认 path 空节点,P47;见 FormModuleKeys#anchorCustomIds) */ - private boolean hasScopeAllAnchor(BoaUser user) { - return FormModuleKeys.anchorCustomIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID); + /** + * 管理员两表在这张表单上应得的范围值(不给=null,交给矩阵)。 + * + *

两张表的映射口径逐字相同(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定; + * 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。

+ */ + private DataScope grantedByAdminTables(BoaUser user, Form form) { + if (form == null || form.getSchId() == null) { + return null; + } + Long ownSch = sessionSchId(user); + if (ownSch == null) { + // 会话给不出学校:既没有"本校表单"可认,SCHOOL 也没有比对基准——不给任何升级 + return null; + } + boolean schoolAdmin = adminScopeSource.isSchoolAdmin(ownSch, user.getId()); + if (!schoolAdmin && !adminScopeSource.isFormAdmin(form.getUuid(), user.getId())) { + return null; + } + long formSch = form.getSchId(); + if (formSch == ownSch) { + return DataScope.ALL; + } + if (formSch == Form.PLATFORM_SCH_ID) { + return DataScope.SCHOOL; + } + // 他校表单:两表都不覆盖(校级管理员的授权域=会话校,跨校不成立) + return null; } /** @@ -163,4 +192,10 @@ public class DataScopeService { // 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底 return DataScope.SELF_SUBMITTED; } + + /** 会话校(Integer→Long 换算;无当前身份或无学校归属=null,两表升级不参与) */ + private static Long sessionSchId(BoaUser user) { + Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId(); + return schId == null ? null : Long.valueOf(schId); + } } diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java index 0859452..dbb009f 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java +++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java @@ -18,15 +18,18 @@ import java.util.Set; * 因此不存在"菜单看得见、接口调不动"的两套口径):

*
    *
  • 定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats}, - * 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。
  • + * 应用设置={@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 移除角色硬编码时保留它。
  • *
* - *

与管理权正交的另一条线:数据面能看哪些行由契约 §8 的角色矩阵 + customId 决定 + *

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

+ * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。 + * 管理员两表不充当页面入口凭证(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里 + * 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。

*/ @Service @RequiredArgsConstructor @@ -41,8 +44,11 @@ public class FormAdminGuard { public static final String MODULE_DATA_MANAGE = "/admin/data"; /** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */ public static final String MODULE_STATS = "/admin/stats"; - /** 字典管理模块行 path */ - public static final String MODULE_DICT = "/admin/dicts"; + /** + * 应用设置模块行 path(T10 方案 §3,取代原字典管理行 {@code /admin/dicts}): + * 页内两个 tab(角色设置/字典管理)共用这一行,字典 API 也摊它把关。 + */ + public static final String MODULE_APP_SETTINGS = "/admin/app"; private final ModuleTreeSource moduleTreeSource; @@ -136,6 +142,46 @@ public class FormAdminGuard { "无该模块权限(需任一):" + 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 (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 = currentSchId(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)); diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java index 7d8744a..909afce 100644 --- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java +++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java @@ -229,6 +229,18 @@ public class FormDefService { return form; } + /** + * 数据面读接口入口把关用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定, + * 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}—— + * 那里才知道"他校表单与不存在的表单要用同一口径回答",本方法刻意不给拒绝话术。 + */ + public Form findFormForGuard(String formUuid) { + if (formUuid == null || formUuid.trim().isEmpty()) { + return null; + } + return formRepository.findByUuid(formUuid.trim()).orElse(null); + } + // ==================== 写:草稿/发布/生命周期 ==================== /** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */ diff --git a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java index cda853b..a794488 100644 --- a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java +++ b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java @@ -8,12 +8,12 @@ import java.util.List; /** * 会话用户在本应用下的已授权模块树来源(方案 §6.1 第②层,权限模型的唯一权威载体)。 * - *

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

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

本树回答管理权一类问题(执行手册 §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 装配的出站链负责); @@ -24,6 +24,6 @@ import java.util.List; */ public interface ModuleTreeSource { - /** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */ + /** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */ List authorizedModules(BoaUser user); } diff --git a/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java new file mode 100644 index 0000000..65fbb79 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java @@ -0,0 +1,45 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.enums.FormEnums.DataScope; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.stereotype.Component; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; + +/** + * 范围实现器:本校提交({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。 + * + *

它是管理员两表在基础表单(sch_id=0)上的落地形态:校级/单表单管理员都不因授权而看见 + * 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"—— + * 那等于把本校范围读成全平台。

+ * + *

{@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放", + * 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明 + * 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。

+ */ +@Component +public class SchoolScopeImplementor implements AnswerScopeImplementor { + + @Override + public DataScope scope() { + return DataScope.SCHOOL; + } + + @Override + public boolean selectable() { + return false; + } + + @Override + public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { + if (viewerSchId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "数据范围 本校提交 需要会话学校身份"); + } + // NULL 存量行天然不匹配等值谓词(fail-closed,方案 §4 存量条款),不需要额外的 is null 分支 + return cb.equal(root.get("submitterSchId"), viewerSchId); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java b/src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java new file mode 100644 index 0000000..27ff299 --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java @@ -0,0 +1,161 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.model.SchoolModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiGroupClient; +import com.yunhai.platform.openapi.clazz.YschClassOpenApi; +import com.yunhai.platform.openapi.clazz.model.SchTea; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 本校组织树(选人弹窗的数据源,T10 方案 §5.3 末段"同 notice 形态,天然限本校树")。 + * + *

口径抄的是平台现行实现:组树只取骨架并整棵剔除学生组({@code type=8}),成员来自 userclass + * 的教师列表({@code YschClassOpenApi.getSchTeaList})按所属组挂载——notice 的 {@code GetSchoolAll} 与 + * flow-center 的 {@code /admin/user/teacherTree} 都是这一条。不走 {@code getGroupUsers}:那条会把 + * 管理员等非教师账号混进树(flow-center 注释里写明了这是"选人数据不对"的根因)。

+ * + *

教师列表拉不到时只回组骨架并打 warn(同上先例):空组会被剪掉,前端看到的是"只有学校根节点" + * 的空树,比抛 500 更能在实拍里被发现;但组树本身取不到是硬失败(域都给不出,不能假装没人)。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SchoolUserTreeService { + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP)——管理员候选只可能是教职工 */ + private static final int GROUP_TYPE_STUDENT = 8; + + private final OpenApiGroupClient groupClient; + + /** 教师版组织树(根=学校组,叶子=教师);{@code schId} 只从会话域来,不接受外部传入 */ + public List teacherTree(Long schId) { + if (schId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能取本校组织树"); + } + BoaGroup subtree; + try { + subtree = groupClient.getGroupById(schId.intValue(), true); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage()); + } + if (subtree == null || subtree.getId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")"); + } + + Map groups = new LinkedHashMap<>(); + List flat = new ArrayList<>(); + flattenGroups(subtree, flat); + for (BoaGroup group : flat) { + SchoolModels.Node node = new SchoolModels.Node(); + node.setId(group.getId()); + node.setName(group.getName() == null ? "" : group.getName()); + node.setIsParent(true); + groups.put(group.getId(), node); + } + attachTeachers(groups, schId); + for (BoaGroup group : flat) { + for (BoaGroup child : group.getChildren() == null + ? Collections.emptyList() : group.getChildren()) { + SchoolModels.Node childNode = child == null ? null : groups.get(child.getId()); + SchoolModels.Node parent = groups.get(group.getId()); + if (childNode != null && parent != null) { + parent.getChildren().add(childNode); + } + } + } + SchoolModels.Node root = groups.get(schId.intValue()); + if (root == null) { + return new ArrayList<>(); + } + pruneEmptyGroups(root); + return Collections.singletonList(root); + } + + /** 教师按所属组挂叶子;归属组全被剔除(如只在学生组)时兜底挂学校根 */ + private void attachTeachers(Map groups, Long schId) { + List teachers; + try { + teachers = YschClassOpenApi.getSchTeaList(schId.intValue()); + } catch (Exception e) { + log.warn("[选人树] 教师列表拉取失败,本轮只回组骨架 schId={}:{}", schId, e.getMessage()); + return; + } + if (teachers == null) { + return; + } + for (SchTea tea : teachers) { + if (tea == null || tea.getUserTeaSerId() == null) { + continue; + } + SchoolModels.Node leaf = new SchoolModels.Node(); + leaf.setId(tea.getUserTeaSerId()); + leaf.setName(tea.getTeaName() == null ? "" : tea.getTeaName()); + leaf.setIsParent(false); + List groupIds = tea.getGroupIds(); + if ((groupIds == null || groupIds.isEmpty()) && tea.getGroupId() != null) { + groupIds = Collections.singletonList(tea.getGroupId()); + } + boolean attached = false; + if (groupIds != null) { + for (Integer groupId : groupIds) { + SchoolModels.Node group = groups.get(groupId); + if (group != null) { + group.getChildren().add(leaf); + attached = true; + } + } + } + if (!attached) { + SchoolModels.Node root = groups.get(schId.intValue()); + if (root != null) { + root.getChildren().add(leaf); + } + } + } + } + + /** 组子树扁平化;学生组(type=8)整棵剔除(含其后代) */ + private void flattenGroups(BoaGroup group, List out) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + out.add(group); + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroups(child, out); + } + } + } + + /** + * 剪掉没有任何教师的空组(否则选人树里全是点不动的文件夹)。返回该子树里是否还有人员叶子; + * 学校根节点由调用方无条件保留,所以"本校一个教师都没拉到"时前端看到的是一棵只有根的空树。 + */ + private boolean pruneEmptyGroups(SchoolModels.Node node) { + if (!Boolean.TRUE.equals(node.getIsParent())) { + return true; + } + List kept = new ArrayList<>(); + for (SchoolModels.Node child : node.getChildren()) { + if (pruneEmptyGroups(child)) { + kept.add(child); + } + } + node.setChildren(kept); + return !kept.isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java index 0936f24..b2cff62 100644 --- a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java @@ -29,7 +29,7 @@ public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor { } @Override - public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) { + public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { return cb.equal(root.get("submitterId"), userId); } } diff --git a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java index 5bd2a95..e66b8df 100644 --- a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java +++ b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java @@ -35,7 +35,7 @@ public class TeachingClassScopeImplementor implements AnswerScopeImplementor { } @Override - public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) { + public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) { // 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析 throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)"); } diff --git a/src/main/java/com/yunhai/formcenter/service/package-info.java b/src/main/java/com/yunhai/formcenter/service/package-info.java index c808b26..cbffce2 100644 --- a/src/main/java/com/yunhai/formcenter/service/package-info.java +++ b/src/main/java/com/yunhai/formcenter/service/package-info.java @@ -1,6 +1,6 @@ /** * 表单中心服务域(T2 起填充):定义域(发布/升版/复制/字典/schema 校验器)、 - * 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + fc:scope:all)、 + * 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + 管理员两表)、 * 检索统计与导出、上传模块(FILE_PATH/FILE_WEB_PATH,值只存 {id,fileName,key})。 */ package com.yunhai.formcenter.service; 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 cac82f2..9fd544d 100644 --- a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java +++ b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java @@ -1,5 +1,6 @@ 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.Test; @@ -46,7 +47,7 @@ class FormBaseControllerTest { @Test void toFrontendTree_shouldAlwaysEmitChildrenAsArray() { - BoaModule configuredLeaf = module(601, "102", "/admin/dicts", 1); + BoaModule configuredLeaf = module(601, "102", "/admin/app", 1); configuredLeaf.setChildren(new ArrayList<>()); BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null @@ -60,11 +61,11 @@ class FormBaseControllerTest { @Test void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() { - // 数据范围权限锚点 fc:scope:all(契约 §8)就是非数字 customId:它 path 为空、不进导航, - // 只由后端扫描授权集合消费,这里只保证映射不炸、modId 退回主键。 - BoaModule scopeAnchor = module(701, "fc:scope:all", null, 1); + // 手册 §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(scopeAnchor)); + 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(前端字符串比较不判空)"); @@ -90,14 +91,23 @@ class FormBaseControllerTest { BoaUser user = new BoaUser(); user.setId(10133); - List> tree = new FormBaseController().getUserMod(user, "honor"); + List> tree = controller().getUserMod(user, "honor"); assertTrue(tree.isEmpty(), "H4(UserBase appCode 参数)未上线,目标应用一律空树"); } @Test void getUserMod_shouldReturnEmptyTreeWithoutSessionUser() { - assertTrue(new FormBaseController().getUserMod(null, null).isEmpty()); + 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) { diff --git a/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java b/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java new file mode 100644 index 0000000..68ed474 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java @@ -0,0 +1,215 @@ +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.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.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 管理员两表管理面集成测试(真 MySQL;自验项 V45 与 V46 的守卫半边)。 + * + *

断言的是写侧三条规则本身:uk 幂等、防呆(最后一员不可删)、跨校拒绝。入口凭证的判定 + * 走 {@link FormAdminGuard}(本类不启 MockMvc,与 T2~T9 的服务级 IT 同一取证形态), + * 真会话的"授权→登录→菜单→数据范围→导出"端到端在遗留 #7 真会话轮补跑。

+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("管理员两表管理面 - 幂等/防呆/跨校(V45)与应用设置把关(V46)") +class AdminDomainIT extends AnswerTestSupport { + + @Autowired + private AdminDomainService adminService; + + @Autowired + private FormAdminGuard adminGuard; + + private static final int TEACHER_A = 20265001; + private static final int TEACHER_B = 20265002; + /** 守卫反例的会话用户 id 自增位:同一个测试里两个反例必须是两个人,否则授权会叠在同一个 id 上 */ + private static final java.util.concurrent.atomic.AtomicInteger USER_SEQ = + new java.util.concurrent.atomic.AtomicInteger(20265100); + + // ==================== V45:校级表单管理员 ==================== + + @Test + @DisplayName("V45 往返:添加即入列表(按加入次序),删除即出列表") + void schoolAdminRoundTrip() { + assertThat(adminService.listSchoolAdmins(SCH)).isEmpty(); + + List after = adminService.addSchoolAdmins(SCH, + Arrays.asList(item(TEACHER_A, "张老师"), item(TEACHER_B, "李老师"))); + assertThat(after).hasSize(2); + assertThat(after.get(0).getUserId()).isEqualTo(TEACHER_A); + assertThat(after.get(0).getUserName()).isEqualTo("张老师"); + + List remaining = adminService.removeSchoolAdmin(SCH, after.get(0).getId()); + assertThat(remaining).hasSize(1); + assertThat(remaining.get(0).getUserId()).isEqualTo(TEACHER_B); + } + + @Test + @DisplayName("V45 幂等:重复添加同一人不报错也不重复建行(uk 兜底的读侧表现)") + void duplicateAddIsIdempotent() { + adminService.addSchoolAdmins(SCH, Collections.singletonList(item(TEACHER_A, "张老师"))); + List again = adminService.addSchoolAdmins(SCH, + Arrays.asList(item(TEACHER_A, "张老师"), item(TEACHER_A, "张老师改名"))); + assertThat(again).as("同一 (sch_id,user_id) 只留一行").hasSize(1); + assertThat(again.get(0).getUserName()).as("已存在的行不覆盖姓名快照").isEqualTo("张老师"); + } + + @Test + @DisplayName("V45 防呆:删到剩 0 拒绝(保留最后一员,否则该校再无人能进角色设置加人)") + void lastSchoolAdminCannotBeRemoved() { + List only = adminService.addSchoolAdmins(SCH, + Collections.singletonList(item(TEACHER_A, "张老师"))); + assertThatThrownBy(() -> adminService.removeSchoolAdmin(SCH, only.get(0).getId())) + .isInstanceOf(BizException.class) + .hasMessageContaining("最后一名不可删除"); + assertThat(adminService.listSchoolAdmins(SCH)).hasSize(1); + } + + @Test + @DisplayName("V45 跨校:名单里的人不属于本次管理域即拒(不信任前端的选人结果)") + void foreignSchoolUserIsRejected() { + subjects.markSchool(TEACHER_B, OTHER_SCH); + assertThatThrownBy(() -> adminService.addSchoolAdmins(SCH, + Arrays.asList(item(TEACHER_A, "张老师"), item(TEACHER_B, "他校老师")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于本次管理域"); + // 整批是一个事务:B 被拒时同批已写入的 A 一起回滚,对外表现为"这批没生效",不留半批 + } + + @Test + @DisplayName("V45 跨校删除:拿别校的行 id 在本校域里删=查无此行(与不存在同一口径)") + void otherSchoolRowIsNotReachable() { + List atOther = adminService.addSchoolAdmins(OTHER_SCH, + Collections.singletonList(item(TEACHER_A, "张老师"))); + assertThatThrownBy(() -> adminService.removeSchoolAdmin(SCH, atOther.get(0).getId())) + .isInstanceOf(BizException.class).hasMessageContaining("校级管理员记录不存在"); + assertThat(adminService.listSchoolAdmins(OTHER_SCH)).hasSize(1); + } + + @Test + @DisplayName("V45 请求形态:空名单、缺 userId、缺姓名都按 PARAM_ERROR 拒(不给 500)") + void malformedRequestsAreRejected() { + assertThatThrownBy(() -> adminService.addSchoolAdmins(SCH, Collections.emptyList())) + .isInstanceOf(BizException.class).hasMessageContaining("至少选择一人"); + assertThatThrownBy(() -> adminService.addSchoolAdmins(SCH, + Collections.singletonList(item(null, "无名")))) + .hasMessageContaining("userId"); + assertThatThrownBy(() -> adminService.addSchoolAdmins(SCH, + Collections.singletonList(item(TEACHER_A, " ")))) + .hasMessageContaining("姓名"); + } + + // ==================== V45:单表单管理员 ==================== + + @Test + @DisplayName("V45 表单管理员往返 + 幂等:指定表单加人、重复加不重复建行、按别的表单删不到") + void formAdminRoundTripAndIdempotent() { + String uuid = publishForm(SCH, "t10_form_admin", + "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":[{\"key\":\"a\",\"label\":\"甲\",\"type\":\"text\"}]}]}"); + + List added = adminService.addFormAdmins( + defService.readDomainForm(SCH, uuid), SCH, + Collections.singletonList(item(TEACHER_A, "张老师"))); + assertThat(added).hasSize(1); + + assertThat(adminService.addFormAdmins(defService.readDomainForm(SCH, uuid), SCH, + Collections.singletonList(item(TEACHER_A, "张老师")))) + .as("uk_form_admin 兜住重复添加").hasSize(1); + + String otherUuid = publishForm(SCH, "t10_form_admin_other", + "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":[{\"key\":\"a\",\"label\":\"甲\",\"type\":\"text\"}]}]}"); + assertThatThrownBy(() -> adminService.removeFormAdmin(defService.readDomainForm(SCH, otherUuid), + added.get(0).getId())) + .isInstanceOf(BizException.class).hasMessageContaining("表单管理员记录不存在"); + + assertThat(adminService.removeFormAdmin(defService.readDomainForm(SCH, uuid), added.get(0).getId())) + .isEmpty(); + } + + @Test + @DisplayName("V45 基础表单(sch_id=0)的管理员按会话校复核:本校的人可以进,超管平台态(域=0)不行") + void baseFormAdminUsesSessionDomain() { + String baseUuid = publishForm(Form.PLATFORM_SCH_ID, "t10_base_form_admin", + "{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":[{\"key\":\"a\",\"label\":\"甲\",\"type\":\"text\"}]}]}"); + // 真实 UserBase 里没有任何一所学校的 id 是 0,域=0 的归属复核必然判不出(这里把桩钉成同一事实) + subjects.markSchool(TEACHER_B, SCH); + + assertThat(adminService.addFormAdmins(defService.readDomainForm(SCH, baseUuid), SCH, + Collections.singletonList(item(TEACHER_A, "张老师")))).hasSize(1); + + assertThatThrownBy(() -> adminService.addFormAdmins( + defService.readDomainForm(Form.PLATFORM_SCH_ID, baseUuid), Form.PLATFORM_SCH_ID, + Collections.singletonList(item(TEACHER_B, "李老师")))) + .as("域=0 不是一所学校,existsInSchool 一律判不出归属") + .isInstanceOf(BizException.class).hasMessageContaining("不属于本次管理域"); + } + + // ==================== 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); + + 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); + } + + @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); + } + + // ==================== 小工具 ==================== + + private static AdminModels.AdminUserItem item(Integer userId, String userName) { + AdminModels.AdminUserItem item = new AdminModels.AdminUserItem(); + item.setUserId(userId); + item.setUserName(userName); + return item; + } + + /** 只授权指定模块行的会话用户(roleId=7 但模块行另算——P21 起角色不给管理权);每次一个独立 id,防同测试内两个反例串权限 */ + private BoaUser schoolUserWith(String... paths) { + BoaUser user = new BoaUser(); + user.setId(USER_SEQ.incrementAndGet()); + user.setType(0); + user.setRoleIds(new ArrayList<>(Collections.singletonList(7))); + BoaRoleInfo current = new BoaRoleInfo(); + 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 new file mode 100644 index 0000000..eb36733 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java @@ -0,0 +1,295 @@ +package com.yunhai.formcenter.service; + +import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.Form; +import com.yunhai.formcenter.enums.FormEnums.DataScope; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.model.ReadModels; +import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 管理员两表的数据范围集成测试(真 MySQL;自验项 V43 / V44 / V47)。 + * + *

取证重点与 {@code DataScopeServiceTest} 互补:单测证"解析出哪个集合",这里证 + * 集合落成的 SQL 谓词真的少读了行——包括 SCHOOL 谓词把 {@code submitter_sch_id} 为 NULL 的 + * 存量行挡在外面(方案 §4 的 fail-closed 半边),以及导出与列表同集(V27 红线的 T10 延伸)。

+ * + *

数据集刻意含"基础表单(sch_id=0)+ 两校各提交一条":这是 {@code SCHOOL} 唯一能被单独分辨出来的 + * 形状(本校表单上升级出的是 ALL,与租户域同向;只有平台域表单的读开口才需要"本校提交"这一档)。

+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("管理员两表范围 - 本校/基础表单/SCHOOL 谓词(V43/V44/V47)") +class AdminScopeIT extends AnswerTestSupport { + + @Autowired + private AnswerReadService readService; + + @Autowired + private AnswerExportService exportService; + + @Autowired + private FormAdminGuard adminGuard; + + @Autowired + private AnswerRepository answerRepository; + + private static final String BASE_CODE = "t10_base"; + private static final String OWN_CODE = "t10_own"; + private static final String QUICK_CODE = "t10_quick_drill"; + /** 快应用面(方案 §6.2):中心之外的 app_code,用来证"本校快应用表单也被校级管理员覆盖" */ + private static final String QUICK_APP = "fc-drill"; + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true}]}]}"; + + // ==================== V43:校级表单管理员 ==================== + + @Test + @DisplayName("V43 命中:本校表单=全部,本校快应用表单同样全部(app_code 不参与判定)") + void schoolAdminSeesAllAnswersOnOwnSchoolForms() { + Seed seed = seed(); + BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1); + assertThat(visible(seed.ownUuid, teacher, SCH)).containsExactly(seed.ownByTeacher); + + grantSchoolAdmin(ReadFixture.TEACHER_1, SCH); + Set upgraded = visible(seed.ownUuid, teacher, SCH); + assertThat(upgraded).as("本校表单升级为全部").contains(seed.ownByStudent, seed.ownByTeacher); + assertThat(scopesOf(seed.ownUuid, teacher, SCH)).containsExactly(DataScope.ALL); + + // 快应用表单(同校、不同 app_code)也在升级覆盖内——教务处可看全校快应用数据 + assertThat(visible(seed.quickUuid, teacher, SCH)).containsExactly(seed.quickByStudent); + } + + @Test + @DisplayName("V43 命中:基础表单=SCHOOL,只见本校提交,他校提交与存量 NULL 行都不可见") + void schoolAdminSeesOnlyOwnSchoolAnswersOnBaseForm() { + Seed seed = seed(); + BoaUser teacher = user(ReadFixture.TEACHER_1, SCH, 1); + // 未进表:教师×面向学生=仅本人提交 + assertThat(visible(seed.baseUuid, teacher, SCH)).containsExactly(seed.baseByTeacher); + + grantSchoolAdmin(ReadFixture.TEACHER_1, SCH); + Set schoolWide = visible(seed.baseUuid, teacher, SCH); + assertThat(schoolWide).as("本校两条可见").contains(seed.baseByStudent, seed.baseByTeacher); + assertThat(schoolWide).as("他校提交不可见(D2=B)").doesNotContain(seed.baseAtOtherSchool); + assertThat(schoolWide).as("submitter_sch_id 为 NULL 的存量行不可见(fail-closed)") + .doesNotContain(seed.baseLegacyNull); + assertThat(scopesOf(seed.baseUuid, teacher, SCH)).contains(DataScope.SCHOOL) + .doesNotContain(DataScope.ALL); + } + + @Test + @DisplayName("V43 未命中:不进两表时矩阵默认一格不变(升级只加不减)") + void withoutAdminRowMatrixIsUnchanged() { + Seed seed = seed(); + BoaUser student = user(ReadFixture.STUDENT_1, SCH, 3); + assertThat(scopesOf(seed.ownUuid, student, SCH)).containsExactly(DataScope.ABOUT_ME); + assertThat(visible(seed.ownUuid, student, SCH)).isEmpty(); + } + + @Test + @DisplayName("V43 会话域跟随:同一个人以他校身份登录,基础表单读到他校那一所(授权跟着会话域走)") + void schoolAdminFollowsSessionDomain() { + Seed seed = seed(); + grantSchoolAdmin(ReadFixture.TEACHER_1, OTHER_SCH); + BoaUser teacherAtOther = user(ReadFixture.TEACHER_1, OTHER_SCH, 1); + Set atOther = visible(seed.baseUuid, teacherAtOther, OTHER_SCH); + assertThat(atOther).containsExactly(seed.baseAtOtherSchool); + assertThat(atOther).doesNotContain(seed.baseByStudent, seed.baseByTeacher); + } + + // ==================== V44:单表单管理员 ==================== + + @Test + @DisplayName("V44 命中:指定表单=全部,未指定的表单不受影响;基础表单上=SCHOOL") + void formAdminUpgradesOnlyItsForm() { + Seed seed = seed(); + BoaUser teacher = user(ReadFixture.TEACHER_2, SCH, 1); + assertThat(visible(seed.ownUuid, teacher, SCH)).isEmpty(); + + grantFormAdmin(seed.ownUuid, ReadFixture.TEACHER_2, SCH); + assertThat(visible(seed.ownUuid, teacher, SCH)) + .as("指定表单升级为全部") + .contains(seed.ownByStudent, seed.ownByTeacher); + assertThat(visible(seed.baseUuid, teacher, SCH)) + .as("未被指定的基础表单不受影响(教师×面向学生的矩阵格=仅本人提交,而他没提交过)") + .isEmpty(); + + grantFormAdmin(seed.baseUuid, ReadFixture.TEACHER_2, Form.PLATFORM_SCH_ID); + Set onBase = visible(seed.baseUuid, teacher, SCH); + assertThat(onBase).as("基础表单上=本校提交(不是全部)") + .contains(seed.baseByStudent, seed.baseByTeacher); + assertThat(onBase).doesNotContain(seed.baseAtOtherSchool, seed.baseLegacyNull); + } + + @Test + @DisplayName("V44 权利=查看+导出:导出行数与列表同集;管理面入口仍要模块行(两表不给维护权)") + void formAdminGetsReadAndExportButNotMaintenance() { + 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() { + 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("无该模块权限"); + } + + // ==================== V47:加列 ==================== + + @Test + @DisplayName("V47 提交落 submitter_sch_id=会话校;超管平台态提交给 NULL 也不报错(列可空)") + void submitWritesSubmitterSchool() { + Seed seed = seed(); + Answer row = answerRepository.findByUuidAndSchId(seed.baseByStudent, SCH).orElseThrow(AssertionError::new); + assertThat(row.getSubmitterSchId()).isEqualTo(SCH); + Answer other = answerRepository.findByUuidAndSchId(seed.baseAtOtherSchool, OTHER_SCH) + .orElseThrow(AssertionError::new); + assertThat(other.getSubmitterSchId()).isEqualTo(OTHER_SCH); + + // updatable=false:编辑答卷不会把列改走(同 sch_id 的钉住语义) + assertThat(answerRepository.findByUuidAndSchId(seed.baseByTeacher, SCH) + .orElseThrow(AssertionError::new).getSubmitterSchId()).isEqualTo(SCH); + } + + // ==================== 数据集 ==================== + + /** 一张基础表单(sch=0)× 两校提交 + 一张本校表单 × 师生各一条 + 一张同校快应用表单 */ + private Seed seed() { + Seed seed = new Seed(); + seed.baseUuid = publishForm(Form.PLATFORM_SCH_ID, BASE_CODE, SCHEMA); + seed.ownUuid = publishForm(SCH, OWN_CODE, SCHEMA); + seed.quickUuid = publishQuickForm(QUICK_APP, QUICK_CODE); + + seed.baseByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), BASE_CODE, "本校学生甲"); + seed.baseByTeacher = submit(user(ReadFixture.TEACHER_1, SCH, 1), BASE_CODE, "本校教师乙"); + seed.baseAtOtherSchool = submit(user(ReadFixture.STUDENT_2, OTHER_SCH, 3), BASE_CODE, "他校学生丙"); + seed.baseLegacyNull = insertLegacyAnswer(seed.baseUuid, "存量没有学校列的行"); + + seed.ownByStudent = submit(user(ReadFixture.STUDENT_1, SCH, 3), OWN_CODE, "自己学校的行"); + seed.ownByTeacher = submit(user(ReadFixture.TEACHER_1, SCH, 1), OWN_CODE, "老师填的行"); + + seed.quickByStudent = submitQuick(user(ReadFixture.STUDENT_1, SCH, 3), "快应用的行"); + return seed; + } + + /** 造一条 T10 之前的存量行(submitter_sch_id=NULL):新链路的提交一定会落值,只有直接入库才出得来 */ + private String insertLegacyAnswer(String formUuid, String name) { + Answer answer = new Answer(); + String uuid = UUID.randomUUID().toString().replace("-", ""); + answer.setUuid(uuid); + answer.setFormUuid(formUuid); + answer.setSchId(SCH); + answer.setAppCode("form-center"); + answer.setCode(BASE_CODE); + answer.setVersion(1); + answer.setSubmitterId(ReadFixture.STUDENT_2); + answer.setStatus("pending"); + answer.setValuesJson("{\"name\":\"" + name + "\"}"); + answer.setIdempotencyKey("legacy-" + uuid); + answer.setIsDeleted(0); + answerRepository.saveAndFlush(answer); + return uuid; + } + + private String submit(BoaUser user, String code, String name) { + return answerService.submit(user, submit(code, "{\"name\":\"" + name + "\"}")).getAnswerId(); + } + + /** 快应用面的提交:显式带 appCode(中心之外的应用,方案 §6.2) */ + private String submitQuick(BoaUser user, String name) { + AnswerModels.SubmitRequest request = submit(QUICK_CODE, "{\"name\":\"" + name + "\"}"); + request.setAppCode(QUICK_APP); + return answerService.submit(user, request).getAnswerId(); + } + + /** 快应用表单挂在非中心 app_code 下:建表→存 schema→发布,与 publishForm 同一条链 */ + private String publishQuickForm(String appCode, String code) { + FormModels.FormCreateRequest create = new FormModels.FormCreateRequest(); + create.setAppCode(appCode); + create.setCode(code); + create.setName("快应用 " + code); + String uuid = defService.create(SCH, create).getFormUuid(); + FormModels.FormSaveRequest save = new FormModels.FormSaveRequest(); + save.setUuid(uuid); + save.setSchemaJson(SCHEMA); + defService.save(SCH, save); + defService.publish(SCH, uuid, null); + return uuid; + } + + private Set visible(String formUuid, BoaUser user, Long domain) { + ReadModels.ListRequest request = new ReadModels.ListRequest(); + request.setFormUuid(formUuid); + request.setSize(50); + return readService.list(user, domain, request).getRows().stream() + .map(ReadModels.RowView::getAnswerId).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private Set scopesOf(String formUuid, BoaUser user, Long domain) { + ReadModels.ListRequest request = new ReadModels.ListRequest(); + request.setFormUuid(formUuid); + return readService.list(user, domain, request).getScopes(); + } + + /** 只数不写的导出靶:V44 要比的是行数,不需要真的产物字节 */ + private static final class CountingOutputStream extends java.io.OutputStream { + @Override + public void write(int b) { + // 丢弃:行数由 AnswerExportService 的返回值给出 + } + } + + private static final class Seed { + String baseUuid; + String ownUuid; + String quickUuid; + String baseByStudent; + String baseByTeacher; + String baseAtOtherSchool; + String baseLegacyNull; + String ownByStudent; + String ownByTeacher; + String quickByStudent; + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java index e297446..2deb9d1 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java @@ -140,14 +140,14 @@ class AnswerReadIT extends AnswerTestSupport { } @Test - @DisplayName("V42 扩展位:给组授权 customId=fc:scope:all 的模块行后,该教师范围升级为全部(与矩阵同一条解析)") - void scopeAllAnchorUpgradesTeacher() { + @DisplayName("V43 扩展位:教师进了 school_admin 后,本校表单范围升级为全部(与矩阵同一条解析)") + void schoolAdminUpgradesTeacherOnOwnForm() { ReadFixture fixture = new ReadFixture(this); ReadFixture.Rows rows = fixture.seed(manager()); BoaUser teacher = fixture.teacher1(); assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Self); - moduleTree.grantCustomId(ReadFixture.TEACHER_1, DataScopeService.SCOPE_ALL_CUSTOM_ID); + grantSchoolAdmin(ReadFixture.TEACHER_1, SCH); ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid)); assertThat(upgraded.getScopes()).containsExactly(DataScope.ALL); assertThat(upgraded.getRows()).hasSize(5); diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java index b3ec4e5..a1c4e9f 100644 --- a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java +++ b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java @@ -1,9 +1,13 @@ package com.yunhai.formcenter.service; import com.yunhai.formcenter.entity.Answer; +import com.yunhai.formcenter.entity.FormAdmin; +import com.yunhai.formcenter.entity.SchoolAdmin; import com.yunhai.formcenter.model.AnswerModels; import com.yunhai.formcenter.model.FormModels; import com.yunhai.formcenter.repository.AnswerRepository; +import com.yunhai.formcenter.repository.FormAdminRepository; +import com.yunhai.formcenter.repository.SchoolAdminRepository; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.BeforeEach; @@ -62,6 +66,12 @@ abstract class AnswerTestSupport { @Autowired protected InMemorySubjectDirectory subjects; + @Autowired + protected SchoolAdminRepository schoolAdminRows; + + @Autowired + protected FormAdminRepository formAdminRows; + @BeforeEach void resetGateways() { moduleTree.reset(); @@ -124,6 +134,31 @@ abstract class AnswerTestSupport { return user; } + // ==================== 管理员两表(T10) ==================== + + /** + * 直接把人选进 {@code school_admin}(范围取证用)。 + * 写侧规则(uk 幂等、防呆、跨校复核)在 {@code AdminDomainIT} 按服务口吻断言, + * 这里绕过服务只是为了让"某人是不是校级管理员"这个事实由用例一手掌握。 + */ + protected void grantSchoolAdmin(int userId, long schId) { + SchoolAdmin row = new SchoolAdmin(); + row.setSchId(schId); + row.setUserId(userId); + row.setUserName("校级管理员 " + userId); + schoolAdminRows.saveAndFlush(row); + } + + /** 直接把人选进 {@code form_admin}(同上,绕过服务层的归属复核) */ + protected void grantFormAdmin(String formUuid, int userId, long schId) { + FormAdmin row = new FormAdmin(); + row.setFormUuid(formUuid); + row.setSchId(schId); + row.setUserId(userId); + row.setUserName("表单管理员 " + userId); + formAdminRows.saveAndFlush(row); + } + // ==================== 建定义 ==================== /** 建表单 → 保存 schema → 发布,返回 uuid */ diff --git a/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java b/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java index 17bd052..84d0646 100644 --- a/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java +++ b/src/test/java/com/yunhai/formcenter/service/DataScopeServiceTest.java @@ -4,7 +4,6 @@ import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums.DataScope; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; import org.junit.jupiter.api.DisplayName; @@ -20,22 +19,26 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * 数据范围解析单测(自验项 V28 全部 + V42 的"身份默认矩阵/customId 升级/回落"三半边)。 + * 数据范围解析单测(自验项 V28 全部 + V42 的"身份默认矩阵/来源升级/回落"三半边 + + * V43/V44 的解析半边)。 * *

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

+ * + *

T10 改版:原"customId 锚点升级"两例(V42 扩展位 / P47 只认 path 空行)随锚点协议退役删除, + * 同一位置改由管理员两表用例承担(V43/V44)。

*/ -@DisplayName("DataScopeService - 契约 §8 矩阵与 customId 升级(V28/V42)") +@DisplayName("DataScopeService - 契约 §8 矩阵与管理员两表升级(V28/V42/V43/V44)") class DataScopeServiceTest { private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode(); private static final int TEACHER_FACING = FormEnums.Audience.TEACHER.getCode(); + private static final long SCH = 990301L; + private static final long OTHER_SCH = 990302L; - private final InMemoryModuleTreeSource tree = new InMemoryModuleTreeSource(); - private final DataScopeService service = new DataScopeService(tree, Arrays.asList( - new AllScopeImplementor(), new SelfSubmittedScopeImplementor(), - new AboutMeScopeImplementor(), new TeachingClassScopeImplementor())); + private final InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource(); + private final DataScopeService service = new DataScopeService(implementors(), admins); // ==================== V42:身份默认矩阵 ==================== @@ -43,33 +46,39 @@ class DataScopeServiceTest { @DisplayName("V42 矩阵:管理员(7)/校领导(2)=全部,两种面向人群都是全部") void adminAndLeaderSeeAll() { for (int roleId : Arrays.asList(7, 2)) { - assertThat(service.resolve(user(roleId), form(STUDENT_FACING))).containsExactly(DataScope.ALL); - assertThat(service.resolve(user(roleId), form(TEACHER_FACING))).containsExactly(DataScope.ALL); + assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.ALL); + assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING))) + .containsExactly(DataScope.ALL); } } @Test @DisplayName("V42 矩阵:教师(1)×面向学生=一期生效仅本人提交(任教班级实现器缺席);×面向教师=仅本人提交") void teacherScopes() { - assertThat(service.resolve(user(1), form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED); - assertThat(service.resolve(user(1), form(TEACHER_FACING))).containsExactly(DataScope.SELF_SUBMITTED); + assertThat(service.resolve(user(1), form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); + assertThat(service.resolve(user(1), form(SCH, "f-own", TEACHER_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); } @Test @DisplayName("V42 矩阵:学生(3)×面向学生=关于我(老师代填的那条也可见);×面向教师按最保守兜底") void studentScopes() { - assertThat(service.resolve(user(3), form(STUDENT_FACING))).containsExactly(DataScope.ABOUT_ME); - assertThat(service.resolve(user(3), form(TEACHER_FACING))).containsExactly(DataScope.SELF_SUBMITTED); + assertThat(service.resolve(user(3), form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.ABOUT_ME); + assertThat(service.resolve(user(3), form(SCH, "f-own", TEACHER_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); } @Test @DisplayName("V42 矩阵:家长(4)/工作号(6)/局领导(5)=回落仅本人提交(两种人群都是)") void peripheralRolesFallBackToSelf() { for (int roleId : Arrays.asList(4, 6, 5)) { - assertThat(service.resolve(user(roleId), form(STUDENT_FACING))) + assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING))) .as("roleId=" + roleId + " 面向学生") .containsExactly(DataScope.SELF_SUBMITTED); - assertThat(service.resolve(user(roleId), form(TEACHER_FACING))) + assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING))) .as("roleId=" + roleId + " 面向教师") .containsExactly(DataScope.SELF_SUBMITTED); } @@ -81,7 +90,8 @@ class DataScopeServiceTest { BoaUser nobody = new BoaUser(); nobody.setId(20001); nobody.setCurrentRole(role(9, 990301)); - assertThat(service.resolve(nobody, form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED); + assertThat(service.resolve(nobody, form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); } @Test @@ -92,73 +102,131 @@ class DataScopeServiceTest { user.setType(0); user.setRoleIds(Arrays.asList(1, 3)); user.setCurrentRole(role(1, 990301)); - Set scopes = service.resolve(user, form(STUDENT_FACING)); + Set scopes = service.resolve(user, form(SCH, "f-own", STUDENT_FACING)); assertThat(scopes).containsExactlyInAnyOrder(DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME); // 其中一格是"全部"时收敛为全部(其余都是它的子集,留着只让读路径多写 OR) user.setRoleIds(Arrays.asList(1, 3, 7)); - assertThat(service.resolve(user, form(STUDENT_FACING))).containsExactly(DataScope.ALL); + assertThat(service.resolve(user, form(SCH, "f-own", STUDENT_FACING))).containsExactly(DataScope.ALL); } - // ==================== V42:customId 升级位 ==================== + // ==================== V43:校级表单管理员 ==================== @Test - @DisplayName("V42 扩展:授权 customId=fc:scope:all 的模块行后,该组成员范围升级为全部") - void customIdAnchorUpgradesToAll() { + @DisplayName("V43 校级管理员:本校表单=全部(含本校快应用表单——解析不看 app_code)") + void schoolAdminSeesAllOnOwnSchoolForms() { BoaUser teacher = user(1); - assertThat(service.resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED); - - tree.grantCustomId(teacher.getId(), DataScopeService.SCOPE_ALL_CUSTOM_ID); - assertThat(service.resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.ALL); - } - - @Test - @DisplayName("customId 扫描是递归的(UserBase 下发的是树,锚点在子层也算命中)且只认 customId 不认 path") - void anchorScanIsRecursiveAndExact() { - BoaUser teacher = user(1); - - InMemoryModuleTreeSource nested = new InMemoryModuleTreeSource(); - nested.grantNested(teacher.getId(), "/admin/data", - Collections.singletonList(InMemoryModuleTreeSource.child(null, - DataScopeService.SCOPE_ALL_CUSTOM_ID))); - assertThat(serviceWith(nested).resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.ALL); - - // 只授权了页面路由行(path)不升级范围——第②层管理权与第③层数据范围是两条正交的口径 - InMemoryModuleTreeSource pathOnly = new InMemoryModuleTreeSource(); - pathOnly.grantPaths(teacher.getId(), "/admin/data", "/admin/forms"); - assertThat(serviceWith(pathOnly).resolve(teacher, form(STUDENT_FACING))) - .containsExactly(DataScope.SELF_SUBMITTED); - } - - @Test - @DisplayName("P47:锚点扫描只认 path 空行——带 path 的导航行误配 fc:scope:all 不升级范围") - void customIdOnNavRowDoesNotUpgradeScope() { - BoaUser teacher = user(1); - - // 误配形态:一行既有页面 path(会被渲染成菜单),又挂了锚点 customId - InMemoryModuleTreeSource misconfigured = new InMemoryModuleTreeSource(); - misconfigured.grantPathWithCustomId(teacher.getId(), "/admin/data", - DataScopeService.SCOPE_ALL_CUSTOM_ID); - assertThat(serviceWith(misconfigured).resolve(teacher, form(STUDENT_FACING))) - .as("契约 §8 的锚点行形态是「path 空 + customId」,导航行上的误配值不参与范围升级") + assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))) + .as("未进表时仍是矩阵默认") .containsExactly(DataScope.SELF_SUBMITTED); - // 对照:同一用户同一值,改挂到 path 空行即升级——证明失败面只在"行形态",不是扫描整体失效 - misconfigured.grantCustomId(teacher.getId(), DataScopeService.SCOPE_ALL_CUSTOM_ID); - assertThat(serviceWith(misconfigured).resolve(teacher, form(STUDENT_FACING))) + admins.grantSchoolAdmin(SCH, teacher.getId()); + assertThat(service.resolve(teacher, form(SCH, "f-quick", STUDENT_FACING))) .containsExactly(DataScope.ALL); } - // ==================== V28:任教班级在枚举里但一期不可选 ==================== + @Test + @DisplayName("V43 校级管理员:基础表单(sch_id=0)升出本校提交,不给全部(D2:跨校学生数据不因授权而可见)") + void schoolAdminSeesSchoolScopeOnBaseForm() { + BoaUser teacher = user(1); + admins.grantSchoolAdmin(SCH, teacher.getId()); + // 矩阵那格(仅本人提交)按并集规则照常留着:SCHOOL 是它的超集,读路径 OR 起来不多给行 + assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING))) + .containsExactlyInAnyOrder(DataScope.SCHOOL, DataScope.SELF_SUBMITTED); + assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING))) + .as("基础表单上不得出现全部") + .doesNotContain(DataScope.ALL); + } @Test - @DisplayName("V28:契约 §8 四值都在枚举里;一期可选集合只有三个(任教班级缺席)") - void teachingClassIsInEnumButNotSelectable() { + @DisplayName("V43 会话域跟随:同一个人是 A 校校级管理员,会话切到 B 校后 A 校授权不跟着过来") + void schoolAdminFollowsSessionDomain() { + BoaUser teacher = user(1); + admins.grantSchoolAdmin(SCH, teacher.getId()); + + // 会话身份=B 校:B 校表单不给升级(他校表单更不给) + BoaUser atOther = user(1); + atOther.setCurrentRole(role(1, (int) OTHER_SCH)); + assertThat(service.resolve(atOther, form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); + assertThat(service.resolve(atOther, form(OTHER_SCH, "f-other", STUDENT_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); + + // 会话身份=A 校:同一张 A 校表单即升级 + assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.ALL); + } + + @Test + @DisplayName("V43 无学校归属的会话不查两表(给不出本校域就没有可升级的授权)") + void noSchoolBindingGrantsNothing() { + BoaUser user = user(1); + user.setCurrentRole(role(1, null)); + admins.grantSchoolAdmin(SCH, user.getId()); + assertThat(service.resolve(user, form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.SELF_SUBMITTED); + } + + // ==================== V44:单表单管理员 ==================== + + @Test + @DisplayName("V44 单表单管理员:指定表单=全部,未指定的表单不受影响") + void formAdminScopedToItsOwnFormOnly() { + BoaUser teacher = user(1); + admins.grantFormAdmin("f-yes", teacher.getId()); + + assertThat(service.resolve(teacher, form(SCH, "f-yes", STUDENT_FACING))).containsExactly(DataScope.ALL); + assertThat(service.resolve(teacher, form(SCH, "f-no", STUDENT_FACING))) + .as("同校但没被指定的表单不受影响") + .containsExactly(DataScope.SELF_SUBMITTED); + } + + @Test + @DisplayName("V44 单表单管理员:基础表单上升出本校提交(管理员行不覆盖未指定的表单)") + void formAdminOnBaseFormIsSchoolScope() { + BoaUser teacher = user(1); + admins.grantFormAdmin("f-base", teacher.getId()); + assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING))) + .containsExactlyInAnyOrder(DataScope.SCHOOL, DataScope.SELF_SUBMITTED); + assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-other", STUDENT_FACING))) + .as("同一张基础表单之外的表单不受影响") + .containsExactly(DataScope.SELF_SUBMITTED); + } + + @Test + @DisplayName("V43/V44 收敛:升级出的全部与矩阵其它格并集后收敛为全部(命中 ALL 不留剩余 OR)") + void upgradeToAllCollapsesTheUnion() { + BoaUser studentAndTeacher = new BoaUser(); + studentAndTeacher.setId(20003); + studentAndTeacher.setType(0); + studentAndTeacher.setRoleIds(Arrays.asList(1, 3)); + studentAndTeacher.setCurrentRole(role(3, (int) SCH)); + admins.grantSchoolAdmin(SCH, studentAndTeacher.getId()); + assertThat(service.resolve(studentAndTeacher, form(SCH, "f-own", STUDENT_FACING))) + .containsExactly(DataScope.ALL); + } + + @Test + @DisplayName("两表都进=同一个升级值,并集不产生重复;矩阵那格仍在(并集语义不因升级而丢格)") + void bothTablesUnionWithMatrix() { + BoaUser teacher = user(3); // 学生身份:矩阵给"关于我" + admins.grantSchoolAdmin(SCH, teacher.getId()); + admins.grantFormAdmin("f-base", teacher.getId()); + assertThat(service.resolve(teacher, form(Form.PLATFORM_SCH_ID, "f-base", STUDENT_FACING))) + .containsExactlyInAnyOrder(DataScope.SCHOOL, DataScope.ABOUT_ME); + } + + // ==================== V28:可选集合与不可用实现器 ==================== + + @Test + @DisplayName("V28:枚举五值都在;一期可选集合只有三个(任教班级未开放、SCHOOL 是授权结果不是筛选项)") + void teachingClassAndSchoolAreNotSelectable() { assertThat(DataScope.values()).containsExactly( - DataScope.ALL, DataScope.TEACHING_CLASS, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME); + DataScope.ALL, DataScope.TEACHING_CLASS, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME, + DataScope.SCHOOL); assertThat(service.selectableScopes()).containsExactlyInAnyOrder( DataScope.ALL, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME); - assertThat(service.selectableScopes()).doesNotContain(DataScope.TEACHING_CLASS); + assertThat(service.selectableScopes()).doesNotContain(DataScope.TEACHING_CLASS, DataScope.SCHOOL); } @Test @@ -166,7 +234,7 @@ class DataScopeServiceTest { void noCombinationResolvesToTeachingClass() { for (int roleId : new int[]{1, 2, 3, 4, 5, 6, 7}) { for (int audience : new int[]{STUDENT_FACING, TEACHER_FACING}) { - assertThat(service.resolve(user(roleId), form(audience))) + assertThat(service.resolve(user(roleId), form(SCH, "f-own", audience))) .as("roleId=" + roleId + " audience=" + audience) .doesNotContain(DataScope.TEACHING_CLASS); } @@ -177,11 +245,19 @@ class DataScopeServiceTest { @DisplayName("V28:任教班级实现器被误用时明确报错(1.5 开放前不存在可用谓词),不会静默按全部放行") void teachingClassImplementorRefusesToBuildPredicate() { assertThatThrownBy(() -> service.implementorOf(DataScope.TEACHING_CLASS) - .rowPredicate(null, null, 1)) + .rowPredicate(null, null, 1, SCH)) .isInstanceOf(BizException.class) .hasMessageContaining("一期未开放"); } + @Test + @DisplayName("V43 半边:本校提交实现器给不出会话学校时明确拒绝(不静默退化成全校)") + void schoolImplementorRefusesWithoutSessionSchool() { + assertThatThrownBy(() -> service.implementorOf(DataScope.SCHOOL).rowPredicate(null, null, 1, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("需要会话学校"); + } + // ==================== 其它 ==================== @Test @@ -190,62 +266,67 @@ class DataScopeServiceTest { BoaUser root = new BoaUser(); root.setId(1); root.setType(1); - assertThat(service.resolve(root, form(TEACHER_FACING))).containsExactly(DataScope.ALL); + assertThat(service.resolve(root, form(SCH, "f-own", TEACHER_FACING))).containsExactly(DataScope.ALL); } @Test @DisplayName("未登录不解析范围(拒绝,而不是给一个兜底范围)") void requiresSession() { - assertThatThrownBy(() -> service.resolve(null, form(STUDENT_FACING))) + assertThatThrownBy(() -> service.resolve(null, form(SCH, "f-own", STUDENT_FACING))) .isInstanceOf(BizException.class).hasMessageContaining("未登录"); - assertThatThrownBy(() -> service.resolve(new BoaUser(), form(STUDENT_FACING))) + assertThatThrownBy(() -> service.resolve(new BoaUser(), form(SCH, "f-own", STUDENT_FACING))) .hasMessageContaining("未登录"); } @Test - @DisplayName("表单为 null(如平台级读上下文缺失)按面向学生列解析,不抛空指针") + @DisplayName("表单为 null(如平台级读上下文缺失)按面向学生列解析,不抛空指针、也不产生两表升级") void nullFormUsesStudentColumn() { + BoaUser teacher = user(1); + admins.grantSchoolAdmin(SCH, teacher.getId()); assertThat(service.resolve(user(3), null)).containsExactly(DataScope.ABOUT_ME); - assertThat(service.resolve(user(1), null)).containsExactly(DataScope.SELF_SUBMITTED); + assertThat(service.resolve(teacher, null)) + .as("表单为 null 时没有归属域可判,两表不升级") + .containsExactly(DataScope.SELF_SUBMITTED); } @Test @DisplayName("实现器注册表按枚举取用;未注册的范围视为配置缺失直接报错(不静默当全部)") void unknownScopeFailsLoud() { - DataScopeService partial = new DataScopeService(tree, - new ArrayList<>(Arrays.asList(new AllScopeImplementor()))); + DataScopeService partial = new DataScopeService( + new ArrayList<>(Collections.singletonList(new AllScopeImplementor())), admins); assertThatThrownBy(() -> partial.implementorOf(DataScope.ABOUT_ME)) .isInstanceOf(BizException.class).hasMessageContaining("缺少实现器"); } // ==================== 构造小工具 ==================== + /** 与生产装配一致的注册表:T10 起五值五实现器(任教班级仍是占位、SCHOOL 新增) */ + private static List implementors() { + return Arrays.asList(new AllScopeImplementor(), new SelfSubmittedScopeImplementor(), + new AboutMeScopeImplementor(), new TeachingClassScopeImplementor(), new SchoolScopeImplementor()); + } + private BoaUser user(int roleId) { BoaUser user = new BoaUser(); user.setId(20000 + roleId); user.setType(0); user.setRoleIds(Collections.singletonList(roleId)); - user.setCurrentRole(role(roleId, 990301)); + user.setCurrentRole(role(roleId, (int) SCH)); return user; } - private BoaRoleInfo role(Integer roleId, int schId) { + private BoaRoleInfo role(Integer roleId, Integer schId) { BoaRoleInfo info = new BoaRoleInfo(); info.setRoleId(roleId); info.setSchId(schId); return info; } - private Form form(int audience) { + private Form form(long schId, String uuid, int audience) { Form form = new Form(); + form.setSchId(schId); + form.setUuid(uuid); form.setAudience(audience); return form; } - - /** 换一个模块树桩组同一个解析器(实现器集合与生产装配一致:四值四个实现器) */ - private DataScopeService serviceWith(InMemoryModuleTreeSource source) { - return new DataScopeService(source, Arrays.asList( - new AllScopeImplementor(), new SelfSubmittedScopeImplementor(), - new AboutMeScopeImplementor(), new TeachingClassScopeImplementor())); - } } diff --git a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java index 7ee1234..84585d9 100644 --- a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java +++ b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java @@ -1,5 +1,6 @@ package com.yunhai.formcenter.service; +import com.yunhai.formcenter.entity.Form; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaRoleInfo; @@ -17,20 +18,22 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * 管理权闸门单测(执行手册 §5 P21 裁决的 T4 到期替换:授权模块集口径,roleId 硬编码整体移除)。 + * 管理权闸门单测(执行手册 §5 P21 裁决的 T4 到期替换:授权模块集口径,roleId 硬编码整体移除; + * T10 再改:字典管理行撤、应用设置行接手 + 数据面入口判定 {@code requireDataView})。 * - *

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

+ *

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

* *

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

*/ -@DisplayName("FormAdminGuard - 授权模块集口径(P21 替换后)") +@DisplayName("FormAdminGuard - 授权模块集口径与数据面入口判定(P21/T10)") class FormAdminGuardTest { private static final long SCH = 880001L; + private static final long OTHER_SCH = 880002L; @Test @DisplayName("被授权数据管理模块行=可进数据面;同 roleId 但未授权=拒(管理权不再来自角色)") @@ -51,13 +54,13 @@ class FormAdminGuardTest { } @Test - @DisplayName("四个模块行互不通用:只有定义管理权的人调字典面被拒(口径=手册 §2.2 一行一权)") + @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_DICT)) - .hasMessageContaining(FormAdminGuard.MODULE_DICT); + 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); } @@ -71,18 +74,18 @@ class FormAdminGuardTest { assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE) .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); - assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_DICT) + assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_APP_SETTINGS) .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE)) .isInstanceOf(BizException.class).hasMessageContaining("需任一"); } @Test - @DisplayName("path 空的权限锚点(customId=fc:scope:all)不给管理权——它是数据范围升级位,不是模块行") - void scopeAnchorIsNotAModuleRow() { + @DisplayName("path 空的权限位(快应用按钮锚点那种行)不给中心管理权——中心守卫只认页面路由行") + void blankPathAnchorIsNotAModuleRow() { FormAdminGuard guard = new FormAdminGuard(user -> { BoaModule anchor = new BoaModule(); - anchor.setCustomId(DataScopeService.SCOPE_ALL_CUSTOM_ID); + anchor.setCustomId("303"); // 快应用面的"导入"锚点形态:path 空 + 数字 customId anchor.setChildren(new ArrayList()); List rows = new ArrayList<>(); rows.add(anchor); @@ -94,11 +97,11 @@ class FormAdminGuardTest { } @Test - @DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有字典管理没有;未授权没有") + @DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有应用设置没有;未授权没有") void canManageFollowsModuleSet() { assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE).canManage(schoolUser(1))).isTrue(); assertThat(guardWith(FormAdminGuard.MODULE_FORM_DESIGN).canManage(schoolUser(1))).isTrue(); - assertThat(guardWith(FormAdminGuard.MODULE_DICT).canManage(schoolUser(7))).isFalse(); + assertThat(guardWith(FormAdminGuard.MODULE_APP_SETTINGS).canManage(schoolUser(7))).isFalse(); assertThat(guardWith().canManage(schoolUser(7))).isFalse(); } @@ -120,7 +123,7 @@ class FormAdminGuardTest { BoaUser root = new BoaUser(); root.setId(1); root.setType(1); - assertThat(guard.requireAdmin(root, 0L, FormAdminGuard.MODULE_DICT)).isEqualTo(0L); + 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); @@ -144,6 +147,8 @@ class FormAdminGuardTest { .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(); } @@ -165,7 +170,67 @@ class FormAdminGuardTest { .doesNotThrowAnyException(); } - /** 授权集合直给的守卫(模块行 path 变参;path 空行不在此列,见锚点用例) */ + // ==================== 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<>(); @@ -194,4 +259,11 @@ class FormAdminGuardTest { 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/InMemoryAdminScopeSource.java b/src/test/java/com/yunhai/formcenter/service/InMemoryAdminScopeSource.java new file mode 100644 index 0000000..39f8bc1 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/InMemoryAdminScopeSource.java @@ -0,0 +1,45 @@ +package com.yunhai.formcenter.service; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link AdminScopeSource} 的测试桩(T10:本地单测不连库,两表授权注入这里取证)。 + * + *

缺省=谁都不在表里(fail-closed,与生产一致);需要授权的用例显式 {@code grant}。 + * 集成测试不用本桩——两表就是本库的表,直接插真行更能证 uk 幂等与谓词生效。

+ */ +public class InMemoryAdminScopeSource implements AdminScopeSource { + + private final Map> schoolAdmins = new ConcurrentHashMap<>(); + private final Map> formAdmins = new ConcurrentHashMap<>(); + + public void grantSchoolAdmin(Long schId, Integer userId) { + schoolAdmins.computeIfAbsent(schId, key -> Collections.newSetFromMap(new ConcurrentHashMap<>())) + .add(userId); + } + + public void grantFormAdmin(String formUuid, Integer userId) { + formAdmins.computeIfAbsent(formUuid, key -> Collections.newSetFromMap(new ConcurrentHashMap<>())) + .add(userId); + } + + public void reset() { + schoolAdmins.clear(); + formAdmins.clear(); + } + + @Override + public boolean isSchoolAdmin(Long schId, Integer userId) { + Set ids = schId == null ? null : schoolAdmins.get(schId); + return ids != null && userId != null && ids.contains(userId); + } + + @Override + public boolean isFormAdmin(String formUuid, Integer userId) { + Set ids = formUuid == null ? null : formAdmins.get(formUuid); + return ids != null && userId != null && ids.contains(userId); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java b/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java index 89e556c..aac7f1a 100644 --- a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java +++ b/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java @@ -10,12 +10,14 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** - * {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的授权模块集口径 - * 与契约 §8 的 customId 扫描都注入这里取证,真实链路 T9 联调验)。 + * {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的管理权口径注入这里取证, + * 真实链路 T9 联调验)。 * - *

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

+ *

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

+ * + *

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

*/ public class InMemoryModuleTreeSource implements ModuleTreeSource { @@ -25,23 +27,10 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource { public void grantPaths(Integer userId, String... paths) { List rows = rows(userId); for (String path : paths) { - rows.add(row(path, null)); + rows.add(row(path)); } } - /** 授权 path 空的权限锚点(数据范围升级位 {@code fc:scope:all} 就是这种形态) */ - public void grantCustomId(Integer userId, String customId) { - rows(userId).add(row(null, customId)); - } - - /** - * 误配形态(P47 取证用):一条会进导航的页面路由行同时挂了 customId。 - * 契约 §8 的锚点行形态是「path 空 + customId」,这种行不该被当成锚点消费。 - */ - public void grantPathWithCustomId(Integer userId, String path, String customId) { - rows(userId).add(row(path, customId)); - } - /** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */ public void clear(Integer userId) { byUser.remove(userId); @@ -52,16 +41,6 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource { byUser.clear(); } - public List pathsOf(Integer userId) { - List paths = new ArrayList<>(); - for (BoaModule module : byUser.getOrDefault(userId, Collections.emptyList())) { - if (module.getPath() != null) { - paths.add(module.getPath()); - } - } - return paths; - } - @Override public List authorizedModules(BoaUser user) { if (user == null || user.getId() == null) { @@ -76,11 +55,10 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource { return byUser.computeIfAbsent(userId, key -> Collections.synchronizedList(new ArrayList<>())); } - private static BoaModule row(String path, String customId) { + private static BoaModule row(String path) { BoaModule module = new BoaModule(); module.setPath(path); - module.setCustomId(customId); - module.setName(path == null ? "权限锚点 " + customId : "模块 " + path); + module.setName("模块 " + path); module.setChildren(new ArrayList()); return module; } @@ -89,15 +67,4 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource { public void grantManageDefaults(Integer userId) { grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN); } - - /** 树形下发形态(有子节点):验证扫描是递归的,不只读顶层 */ - public void grantNested(Integer userId, String parentPath, List children) { - BoaModule parent = row(parentPath, null); - parent.setChildren(new ArrayList<>(children)); - rows(userId).add(parent); - } - - public static BoaModule child(String path, String customId) { - return row(path, customId); - } }