feat(t10-admin): 管理员体系——校级管理员+单表单管理员+锚点退役
build / build-push (push) Successful in 2m12s

- 新增 school_admin/form_admin 两表(sql/t10_admin_domain.sql,含 answer.submitter_sch_id 加列)与 AdminDomainController/AdminDomainService/SchoolUserTreeService
- DataScopeService 范围体系按 P80 验收口径落地:入口只认模块行、查询域=会话校、两表只升范围;新增 SchoolScopeImplementor
- fc:scope:all / SCOPE_ALL_CUSTOM_ID / anchorCustomIds / MODULE_DICT 锚点全链路退役,全仓引用归零;h2 登记脚本 6→5 行(快应用段逐字未动)
- AnswerScopeQueryBuilder 校域不变式:answer.sch_id 恒=会话校,基础过滤 sch_id+app_code+code+is_deleted 不变
- FormAdminGuard:targetSchId=0(基础表单)仅超管可维护;FormAdminController/AnswerReadController 等按管理员范围收口
- 测试基线 190→219/0/0/0:DataScopeServiceTest 22、FormAdminGuardTest 14、AdminScopeIT 8、AdminDomainIT 10、AnswerReadIT 改写 1、FormBaseControllerTest 7 等
This commit is contained in:
chenxing
2026-10-15 20:25:03 +08:00
parent bf39c220f2
commit dc76574118
42 changed files with 1921 additions and 274 deletions
+28 -27
View File
@@ -1,9 +1,10 @@
-- ============================================================================ -- ============================================================================
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本 -- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
-- 编制:Qoder(T9 联调轮)|日期:2026-10-15 -- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役)
-- 依据:《表单中心执行手册》§2.2(中心应用六条模块行表 + 验证口径)、 -- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、
-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.4(customId=fc:scope:all)、 -- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.5(中心=BUSINESS 应用,
-- §6.5(中心=BUSINESS 应用,模块行与快应用模块行两棵树不混) -- 模块行与快应用模块行两棵树不混)
-- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役)
-- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法) -- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法)
-- --
-- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。 -- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。
@@ -37,14 +38,17 @@ SET @operator = 0; -- create_user 占位(0=系统)
-- WHERE @center_app_id = 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/forms ②数据管理 /admin/data ③统计 /admin/stats
-- ④字典管理 /admin/dicts ⑤我的填写 /my(全员) ⑥数据全量【path 留空 + customId=fc:scope:all】 -- ④应用设置 /admin/app(页内 tab:角色设置/字典管理,字典 API 摊这一行把关) ⑤我的填写 /my(全员)
-- 第⑥行=纯权限锚点:导航不渲染、后端 DataScopeService 只扫 path 空节点的 customId(P47 收紧后口径), -- v1.5 改版(T10,2026-10-15 用户裁决):
-- 授权给哪个组,该组的数据范围即升级为"全部"(方案 §6.4)。 -- · 原「字典管理 /admin/dicts」独立行撤销,迁入应用设置页内 tab(不占 modId、不进导航);
-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail) -- · 原「数据全量」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 归位。 -- **不建模块行**(手册 §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 INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time) (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 '/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, '数据管理', '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, '统计', '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() 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 ) t
WHERE @center_app_id > 0 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); 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 NOT EXISTS (SELECT 1 FROM sys_group_app_rel r
WHERE r.group_id = a.group_id AND r.app_id = a.app_id); WHERE r.group_id = a.group_id AND r.app_id = a.app_id);
-- 3.3 中心模块授权:管理员组=前五行(除锚点),全员组=只"我的填写", -- 3.3 中心模块授权:管理员组=四个管理行(表单设计/数据管理/统计/应用设置),全员组=只"我的填写"。
-- 要看全校数据的组=额外勾"数据全量"锚点行(customId=fc:scope:all) -- v1.5 起**没有"要看全校数据就多勾一行"这回事**:数据范围升级改由中心的管理员两表给
-- (/admin/app 的角色设置 tab 加校级表单管理员,或表单列表/设计器给单表单管理员)。
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_admin, m.id, 0, @operator, NOW() SELECT @grp_admin, m.id, 0, @operator, NOW()
FROM app_module m 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); 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) 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' 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); 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 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行 -- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time) INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_teacher, m.id, 0, @operator, NOW() SELECT @grp_teacher, m.id, 0, @operator, NOW()
@@ -172,21 +168,26 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW()
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行) -- 步骤 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 那棵树 -- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通; -- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。 -- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(锚点判定生效,P76) -- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76)
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报 -- ⑤学生组账号: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 UNION ALL
SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id
UNION ALL 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 = '') WHERE app_id = @center_app_id AND (path IS NULL OR path = '')
UNION ALL 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 SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id
UNION ALL UNION ALL
SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p
+64
View File
@@ -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(新库)
@@ -7,14 +7,14 @@ import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。 * 授权模块树的权限键扁平化(方案 §6.1 第②层)。
* *
* <p>模块行有两种身份,<b>必须分开取</b>:</p> * <p><b>只收 {@code path} 非空的页面路由行</b>:管理权按它判(手册 §2.2 表)。{@code path} 空的行是
* <ul> * 纯权限位,不进导航——中心应用 v1.5 起不再有这种行,该形态只存在于快应用面的按钮锚点
* <li>{@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);</li> * (导入/导出,由前端 {@code modulePerms} 直读原始树 customId 消费,后端不参与)。</p>
* <li>{@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位, *
* 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。</li> * <p>数据范围升级位(UserBase 侧的 customId 锚点协议)已在 T10 退役,载体换成管理员两表
* </ul> * ({@code AdminScopeSource},管理员体系方案 §5.1/§7),所以本类不再提供 customId 侧的取键方法。</p>
* *
* <p>纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。</p> * <p>纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。</p>
*/ */
@@ -23,31 +23,17 @@ public final class FormModuleKeys {
private FormModuleKeys() { private FormModuleKeys() {
} }
/** 授权页面路由集合(path 空的权限锚点不在内) */ /** 授权页面路由集合(path 空的权限位不在内) */
public static Set<String> paths(List<BoaModule> tree) { public static Set<String> paths(List<BoaModule> tree) {
Set<String> result = new LinkedHashSet<>(); Set<String> result = new LinkedHashSet<>();
collect(tree, true, result); collect(tree, result);
return result; return result;
} }
/** /**
* 权限锚点的 customId 集合:<b>只收 path 为空的节点</b>(契约 §8「锚点行形态」注记 / P47)。 * 递归展平。取不出 path 的节点自然被跳过,子节点照常下钻(页面行可以挂在分组层下面)。
*
* <p>数据范围升级位({@code fc:scope:all})按契约只应挂在<b>不进导航的 path 空行</b>上,
* 扫描必须按这个形态收:带 path 的导航行若误配了该值,不该把看数据的人悄悄升级成"全校全部"
* ——宽方向的误配同样是不受控的授权膨胀。</p>
*/ */
public static Set<String> anchorCustomIds(List<BoaModule> tree) { private static void collect(List<BoaModule> nodes, Set<String> out) {
Set<String> result = new LinkedHashSet<>();
collect(tree, false, result);
return result;
}
/**
* 递归展平(wantPath=true 取 path;false 取「path 空节点」的 customId)。
* 取不出值的节点自然被跳过,子节点照常下钻(锚点可以挂在分组层下面)。
*/
private static void collect(List<BoaModule> nodes, boolean wantPath, Set<String> out) {
if (nodes == null) { if (nodes == null) {
return; return;
} }
@@ -56,11 +42,10 @@ public final class FormModuleKeys {
continue; continue;
} }
String path = trimToNull(node.getPath()); String path = trimToNull(node.getPath());
String value = wantPath ? path : (path == null ? trimToNull(node.getCustomId()) : null); if (path != null) {
if (value != null) { out.add(path);
out.add(value);
} }
collect(node.getChildren(), wantPath, out); collect(node.getChildren(), out);
} }
} }
@@ -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}(表单设计行)。
*
* <p><b>这两组端点属于维护面,不是数据面</b>:谁能进角色设置页由 UserBase 模块行决定(守卫红线——
* 管理员表本身不作入口凭证),加进去的人只升级数据范围({@code DataScopeService})。
* 目标域一律走 {@code FormAdminGuard} 的会话域校验:校级只本校、超管可显式指定(含代查任意校),
* 名单里的人再逐个复核归属({@code AdminDomainService})。</p>
*
* <p>返回形态:写操作直接回<b>操作后的全量列表</b>(不分页,每校/每表单个位数),
* 前端省一次刷新请求,也就不会出现"删了最后一员、列表还留着那行"的中间态。</p>
*/
@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<AdminModels.AdminView> listSchoolAdmins(BoaUser user,
@RequestParam(required = false) Long schId) {
return adminService.listSchoolAdmins(
adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
}
@PostMapping("/school-admins")
public List<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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);
}
}
@@ -156,41 +156,41 @@ public class FormAdminController {
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body); return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
} }
// ==================== 选项字典(契约 §2.1) ==================== // ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ====================
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */ /** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
@GetMapping("/dict/list") @GetMapping("/dict/list")
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) { public List<FormModels.DictView> 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 本校遮蔽平台,条目只出未删 */ /** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
@GetMapping("/dict/selectable") @GetMapping("/dict/selectable")
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) { public List<FormModels.DictView> 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") @PostMapping("/dict/save")
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { 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") @PostMapping("/dict/delete")
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) { 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 */ /** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
@PostMapping("/dict/item/save") @PostMapping("/dict/item/save")
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) { 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()); body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
} }
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */ /** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
@PostMapping("/dict/item/delete") @PostMapping("/dict/item/delete")
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) { 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) { private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
@@ -6,6 +6,7 @@ import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.AnswerStatsService; import com.yunhai.formcenter.service.AnswerStatsService;
import com.yunhai.formcenter.service.DataScopeService; import com.yunhai.formcenter.service.DataScopeService;
import com.yunhai.formcenter.service.FormAdminGuard; import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
@@ -25,13 +26,14 @@ import java.net.URLEncoder;
import java.util.List; import java.util.List;
/** /**
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。 * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。
* *
* <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p> * <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p>
* <ul> * <ul>
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data}, * <li>能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data}
* 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");</li> * 或统计 {@code /admin/stats} <b>任一</b>,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面;
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId, * 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);</li>
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1;
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li> * 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li>
* </ul> * </ul>
* *
@@ -49,6 +51,7 @@ import java.util.List;
public class FormAnswerReadController { public class FormAnswerReadController {
private final FormAdminGuard adminGuard; private final FormAdminGuard adminGuard;
private final FormDefService defService;
private final AnswerReadService readService; private final AnswerReadService readService;
private final AnswerStatsService statsService; private final AnswerStatsService statsService;
private final AnswerExportService exportService; private final AnswerExportService exportService;
@@ -59,7 +62,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody ReadModels.ListRequest body) { @RequestBody ReadModels.ListRequest body) {
requireFormUuid(body.getFormUuid()); 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); return readService.list(user, domain, body);
} }
@@ -68,7 +71,10 @@ public class FormAnswerReadController {
public ReadModels.DetailView detail(BoaUser user, public ReadModels.DetailView detail(BoaUser user,
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) { @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); return readService.detail(user, domain, answerId);
} }
@@ -78,7 +84,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid) { @RequestParam("formUuid") String formUuid) {
requireFormUuid(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); return readService.filterFields(domain, formUuid);
} }
@@ -88,8 +94,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId, @RequestParam(required = false) Long schId,
@RequestBody ReadModels.QueryRequest body) { @RequestBody ReadModels.QueryRequest body) {
requireFormUuid(body.getFormUuid()); requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdminAny(user, schId, Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
return statsService.stats(user, domain, body); return statsService.stats(user, domain, body);
} }
@@ -106,7 +111,7 @@ public class FormAnswerReadController {
@RequestBody ReadModels.QueryRequest body, @RequestBody ReadModels.QueryRequest body,
HttpServletResponse response) throws IOException { HttpServletResponse response) throws IOException {
requireFormUuid(body.getFormUuid()); 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"; String fileName = "form-answers.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8");
@@ -1,8 +1,12 @@
package com.yunhai.formcenter.controller.api; 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.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -29,11 +33,15 @@ import java.util.Map;
*/ */
@Slf4j @Slf4j
@RestController @RestController
@RequiredArgsConstructor
public class FormBaseController { public class FormBaseController {
/** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */ /** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */
private static final String APP_CODE = "form-center"; private static final String APP_CODE = "form-center";
private final FormAdminGuard adminGuard;
private final SchoolUserTreeService schoolUserTreeService;
/** /**
* appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。 * appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。
* 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。 * 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。
@@ -48,6 +56,21 @@ public class FormBaseController {
return authUserWithDept; return authUserWithDept;
} }
/**
* 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。
*
* <p><b>T10 落地的原因</b>:管理员两表的选人入口要"限本校树"(方案 §5.3 末段),而 UserBase 侧
* 没有给子应用开放"按 app 过滤的组织树"端点,平台现行做法就是子应用自己代理组树——notice 与
* flow-center 各有一份,本端点是同一口径的第三份({@code SchoolUserTreeService})。
* 域只从会话推导({@code schId} 不是入参),持应用设置或表单设计任一模块行才能取树。</p>
*/
@GetMapping("/api/base/GetSchoolAll")
public List<SchoolModels.Node> getSchoolAll(BoaUser authUserWithDept) {
Long domain = adminGuard.requireAdminAny(authUserWithDept, null,
FormAdminGuard.MODULE_APP_SETTINGS, FormAdminGuard.MODULE_FORM_DESIGN);
return schoolUserTreeService.teacherTree(domain);
}
/** /**
* 当前用户已授权模块树(左侧菜单)。 * 当前用户已授权模块树(左侧菜单)。
* <p><b>依赖 UserBase 侧数据</b>:本应用的 {@code app_module} 行({@code path} 须逐字对上 * <p><b>依赖 UserBase 侧数据</b>:本应用的 {@code app_module} 行({@code path} 须逐字对上
@@ -110,8 +133,8 @@ public class FormBaseController {
* modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的 * modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的
* {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。 * {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。
* 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。 * 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。
* 表单中心的 {@code fc:scope:all}(数据范围权限锚点,契约 §8)是<b>非数字</b> customId, * 中心应用的模块行自 T10(手册 §2.2 v1.5 五行版)起 customId 恒为数字 200~204;非数字时回退主键 id
* 且 path 为空、不进导航,故只由后端扫描授权集合消费,不依赖本映射结果。 * 只是防御性兜底,不再有"数据范围权限锚点"依赖这条映射(该协议已于 T10 退役,载体换成管理员两表)。
*/ */
private static Object resolveModId(BoaModule m) { private static Object resolveModId(BoaModule m) {
String customId = m.getCustomId(); String customId = m.getCustomId();
@@ -39,6 +39,8 @@ import javax.persistence.UniqueConstraint;
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"), @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_submitter", columnList = "sch_id, submitter_id"),
@Index(name = "idx_answer_subject", columnList = "subject_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) // 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id)
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。 // ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的, // 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
@@ -82,6 +84,17 @@ public class Answer extends BaseEntity {
@Column(name = "submitter_id", nullable = false, updatable = false) @Column(name = "submitter_id", nullable = false, updatable = false)
private Integer submitterId; private Integer submitterId;
/**
* 填报人会话学校(T10 方案 §4③,D2=B 的落点):范围值 {@code SCHOOL} 的谓词列,
* 提交时从会话 {@code currentRole.schId} 落值、之后不改。
*
* <p>NULL=未记录(存量行),按"不参与校级范围匹配"处理(fail-closed,方案 §4 存量条款)。
* 它与 {@code sch_id} 同源于会话校(校域不变式:见下方建表注记),本列是把"本校提交"
* 写成显式谓词,而不是靠租户域隐含它。</p>
*/
@Column(name = "submitter_sch_id", updatable = false)
private Long submitterSchId;
/** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */ /** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */
@Column(name = "subject_id") @Column(name = "subject_id")
private Integer subjectId; private Integer subjectId;
@@ -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②):被指定表单的<b>数据查看 + 导出</b>授权(D3 裁决,
* 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。
*
* <p>{@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与
* 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为
* {@code SCHOOL}(跨校学生数据不因授权而可见,D2 裁决)。</p>
*/
@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;
}
@@ -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)。
*
* <p><b>它只升级数据范围,不是页面入口凭证</b>(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权
* 模块行判({@code FormAdminGuard}),这张表只让 {@code DataScopeService} 把范围从"仅本人提交"升到
* 本校表单=全部、基础表单=本校提交。</p>
*
* <p>{@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权
* ——解析入口是会话,会话失效即判不到(方案 §8)。</p>
*/
@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;
}
@@ -101,17 +101,22 @@ public final class FormEnums {
} }
/** /**
* 数据范围(契约 §8 / 方案 §6.4):<b>闭集四枚举,一期契约即含全部四值</b>—— * 数据范围(契约 §8 / 方案 §6.4):一期契约的四值闭集 + T10 的 {@link #SCHOOL}——
* 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。 * "定了就不变"指既不删改既有值、也不改既有语义,扩展走"加枚举=加实现器"这条既定通道
* (管理员体系方案 §5.1)。
* *
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK): * <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p> * <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p>
*
* <p>{@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径):
* 它不是筛选器选项,而是管理员两表授权后的<b>解析结果</b>(谓词 {@code submitter_sch_id}=会话学校)。</p>
*/ */
public enum DataScope { public enum DataScope {
ALL("全部"), ALL("全部"),
TEACHING_CLASS("任教班级"), TEACHING_CLASS("任教班级"),
SELF_SUBMITTED("仅本人提交"), SELF_SUBMITTED("仅本人提交"),
ABOUT_ME("关于我"); ABOUT_ME("关于我"),
SCHOOL("本校提交");
private final String label; private final String label;
@@ -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 族)。
*
* <p>与契约 §1 的"库内 id 不外露"唯一有出入的地方是 {@link AdminView#getId()}:
* 方案 §5.3 的删除端点形态就是 {@code DELETE /…/{id}}(管理员行没有 uuid 可用),
* 该 id 只在本人的管理面内出现、且删除前还要按 {@code (id, 会话域)} 复核归属,
* 不构成答卷数据面那种可枚举资源标识。取舍记在 T10 报告。</p>
*/
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<AdminUserItem> users;
}
/** 列表行(每校/每表单个位数,一律不分页,方案 §4) */
@Getter
@Setter
public static class AdminView {
private Long id;
private Integer userId;
private String userName;
}
}
@@ -178,6 +178,8 @@ public final class ReadModels {
private String code; private String code;
private Set<DataScope> scopes; private Set<DataScope> scopes;
private Integer userId; private Integer userId;
/** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */
private Long viewerSchId;
private String status; private String status;
private List<FormFilterOperators.Spec> filters; private List<FormFilterOperators.Spec> filters;
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */ /** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
@@ -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 形态,天然限本校树)。
*
* <p>字段名对齐前端移植自 notice 的 {@code UserCheckModal.vue}:它按 {@code isParent} 判组/人
* (组=文件夹、叶子=人员),按 {@code children} 递归,{@code id/name} 直接进树。</p>
*/
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<Node> children = new ArrayList<>();
}
}
@@ -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)。
*
* <p>{@code uk_form_admin(form_uuid, user_id)} 是重复添加的幂等兜底;{@code idx_form_admin_user}
* 供"我管理的表单"这类按人的反查(v2 入口,本轮不消费,方案 §8 已登记)。</p>
*/
public interface FormAdminRepository extends JpaRepository<FormAdmin, Long> {
List<FormAdmin> findByFormUuidOrderByIdAsc(String formUuid);
Optional<FormAdmin> findByIdAndFormUuid(Long id, String formUuid);
boolean existsByFormUuidAndUserId(String formUuid, Integer userId);
}
@@ -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)。
*
* <p>{@code uk_school_admin(sch_id, user_id)} 是重复添加的幂等兜底;行按 id 升序回显,
* 与前端"序号/姓名/操作"的列表形态同序。</p>
*/
public interface SchoolAdminRepository extends JpaRepository<SchoolAdmin, Long> {
List<SchoolAdmin> findBySchIdOrderByIdAsc(Long schId);
Optional<SchoolAdmin> findByIdAndSchId(Long id, Long schId);
boolean existsBySchIdAndUserId(Long schId, Integer userId);
long countBySchId(Long schId);
}
@@ -29,7 +29,7 @@ public class AboutMeScopeImplementor implements AnswerScopeImplementor {
} }
@Override @Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) { public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("subjectId"), userId); return cb.equal(root.get("subjectId"), userId);
} }
} }
@@ -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。
*
* <p><b>三条写侧纪律</b>:</p>
* <ol>
* <li><b>幂等</b>:{@code uk_school_admin(sch_id,user_id)} / {@code uk_form_admin(form_uuid,user_id)}
* 是重复添加的兜底——已存在的行直接跳过,不报错也不重复建行(沿登记脚本的幂等哲学);</li>
* <li><b>不信任前端</b>:选人弹窗的名单来自 {@code GetSchoolAll()}(天然限本校树),但落库前仍逐个
* 走 {@link SubjectDirectory#existsInSchool} 复核归属——传别人的 userId 一律拒(方案 §5.3 末段);</li>
* <li><b>防呆</b>:校级管理员删到剩 0 拒绝。没有校级管理员的学校仍能用矩阵默认口径读数据,
* 但"删空最后一员"会让此后无人能进角色设置页加人(D6 的自管理闭环断掉),所以宁可按业务规则拒。</li>
* </ol>
*
* <p>行删除一律按 {@code (id, 本次操作域)} 定位({@code findByIdAndSchId}/{@code findByIdAndFormUuid}),
* 跨校/跨表单的 id 探测与"不存在"同一口径回答;{@code user_name} 是展示快照,权威始终在 UserBase。</p>
*/
@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<AdminModels.AdminView> listSchoolAdmins(Long schId) {
List<AdminModels.AdminView> views = new ArrayList<>();
for (SchoolAdmin row : schoolAdminRepository.findBySchIdOrderByIdAsc(schId)) {
views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
}
return views;
}
/** 批量添加(幂等);返回添加后的全量列表,前端免去第二次请求 */
@Transactional
public List<AdminModels.AdminView> addSchoolAdmins(Long schId, List<AdminModels.AdminUserItem> 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<AdminModels.AdminView> 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<AdminModels.AdminView> listFormAdmins(Form form) {
List<AdminModels.AdminView> 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} 不是可复核的学校,
* 归属复核按 <b>域</b> 走:校级操作人=会话校(因此只能把自己学校的人加进基础表单的管理员),
* 超管平台态(域=0)在此被 {@link SubjectDirectory} 判为"不属于本校"而拒,
* 需要给基础表单授权时显式带 {@code schId=某校}。取舍记在 T10 报告。
*/
@Transactional
public List<AdminModels.AdminView> addFormAdmins(Form form, Long domain,
List<AdminModels.AdminUserItem> 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<AdminModels.AdminView> 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<AdminModels.AdminUserItem> requireUsers(List<AdminModels.AdminUserItem> 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;
}
}
@@ -0,0 +1,21 @@
package com.yunhai.formcenter.service;
/**
* 管理员两表的<b>只读判定端口</b>(T10 方案 §5.1):范围解析与守卫都从这里问"这个人在不在
* {@code school_admin} / {@code form_admin} 里",生产实现是 {@code AdminDomainService}(直查本库两表)。
*
* <p><b>为什么做成端口</b>:{@code DataScopeService} 与 {@code FormAdminGuard} 都只消费这两个布尔判定,
* 加宽一侧(比如将来按人反查"我管理的表单",方案 §8 的 v2 入口)不该把仓库与写侧逻辑渗进解析器;
* 同 {@link ModuleTreeSource}/{@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。</p>
*
* <p><b>口径</b>:判定只认会话身份({@code (schId, userId)} / {@code (formUuid, userId)}),
* 查不到即 false(fail-closed)——残留行(教师离校)因会话失效而永远走不到这里,方案 §8。</p>
*/
public interface AdminScopeSource {
/** 该用户是否 {@code schId} 的校级表单管理员 */
boolean isSchoolAdmin(Long schId, Integer userId);
/** 该用户是否这张表单(按 uuid)的单表单管理员 */
boolean isFormAdmin(String formUuid, Integer userId);
}
@@ -8,7 +8,7 @@ import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root; import javax.persistence.criteria.Root;
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */ /** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */
@Component @Component
public class AllScopeImplementor implements AnswerScopeImplementor { public class AllScopeImplementor implements AnswerScopeImplementor {
@@ -23,7 +23,7 @@ public class AllScopeImplementor implements AnswerScopeImplementor {
} }
@Override @Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) { public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
return cb.conjunction(); return cb.conjunction();
} }
} }
@@ -72,7 +72,7 @@ public class AnswerReadService {
/** /**
* 会话身份 + 表单 + 请求 → 读路径查询规格。 * 会话身份 + 表单 + 请求 → 读路径查询规格。
* *
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来 * <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + 管理员两表解析,解析不出来
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的 * (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子), * {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p> * 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
@@ -84,6 +84,8 @@ public class AnswerReadService {
query.setCode(form.getCode()); query.setCode(form.getCode());
query.setScopes(scopeService.resolve(user, form)); query.setScopes(scopeService.resolve(user, form));
query.setUserId(user == null ? null : user.getId()); 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.setStatus(req == null ? null : trimToNull(req.getStatus()));
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters())); query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
return query; return query;
@@ -32,7 +32,9 @@ 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()})=该范围不额外收窄行 * @return 恒真({@code cb.conjunction()})=该范围不额外收窄行
*/ */
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId); Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId);
} }
@@ -180,6 +180,7 @@ public class AnswerScopeQueryBuilder {
copy.setCode(q.getCode()); copy.setCode(q.getCode());
copy.setScopes(q.getScopes()); copy.setScopes(q.getScopes());
copy.setUserId(q.getUserId()); copy.setUserId(q.getUserId());
copy.setViewerSchId(q.getViewerSchId());
copy.setStatus(q.getStatus()); copy.setStatus(q.getStatus());
copy.setFilters(q.getFilters()); copy.setFilters(q.getFilters());
copy.setCursorId(q.getCursorId()); copy.setCursorId(q.getCursorId());
@@ -234,7 +235,8 @@ public class AnswerScopeQueryBuilder {
} }
List<Predicate> ors = new ArrayList<>(); List<Predicate> ors = new ArrayList<>();
for (DataScope scope : scopes) { 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])); return cb.or(ors.toArray(new Predicate[0]));
} }
@@ -135,6 +135,9 @@ public class AnswerService {
answer.setCode(form.getCode()); answer.setCode(form.getCode());
answer.setVersion(published.versionRow().getVersion()); answer.setVersion(published.versionRow().getVersion());
answer.setSubmitterId(submitterId); answer.setSubmitterId(submitterId);
// T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证
// (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)
answer.setSubmitterSchId(schId);
answer.setSubjectId(subjectId); answer.setSubjectId(subjectId);
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs())); answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
answer.setValuesJson(valuesJson); answer.setValuesJson(valuesJson);
@@ -1,6 +1,5 @@
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormModuleKeys;
import com.yunhai.formcenter.entity.Form; import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope; import com.yunhai.formcenter.enums.FormEnums.DataScope;
@@ -19,12 +18,17 @@ import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围, * 数据范围解析器(契约 §8 / 方案 §6.4 / 管理员体系方案 §5.1):把"会话用户 + 这张表单"解析成一组
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。 * 可见范围,交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
* *
* <p><b>解析规则(优先级)</b>:{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵}, * <p><b>解析规则(优先级)</b>:{@code 范围 = 管理员两表授权 ∪ 身份默认矩阵},
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p> * 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
* *
* <p><b>管理员两表来源</b>(T10 起,取代已退役的 {@code customId} 权限锚点行):
* {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级——
* 本校表单=<b>全部</b>(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})=
* <b>本校提交</b>(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。</p>
*
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有 * <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义: * 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p> * 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
@@ -39,18 +43,16 @@ import java.util.Set;
* 4/6/5 仅本人提交 仅本人提交 * 4/6/5 仅本人提交 仅本人提交
* </pre> * </pre>
* *
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(授权模块行), * <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(UserBase 授权模块行,
* 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求), * 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。<b>两条线的事实源不同</b>——模块行来自 UserBase
* 但一个是 path、一个是 customId,不要混用。</p> * ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面
* 入口凭证(方案 §3 三层模型不变)。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class DataScopeService { public class DataScopeService {
/** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */
public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all";
/** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */ /** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */
private static final int SUPER_ADMIN_TYPE = 1; 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_STUDENT = 3;
private static final int ROLE_ADMIN = 7; private static final int ROLE_ADMIN = 7;
private final ModuleTreeSource moduleTreeSource;
private final List<AnswerScopeImplementor> implementors; private final List<AnswerScopeImplementor> implementors;
private final AdminScopeSource adminScopeSource;
/** /**
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。 * 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
* *
* @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理 * @param form 目标表单({@code schId} 决定两表升级成哪一档、{@code audience} 决定用矩阵哪一列);
* null 按面向学生列解析,且不产生两表升级(无表单域可判)
*/ */
public Set<DataScope> resolve(BoaUser user, Form form) { public Set<DataScope> resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
@@ -75,9 +78,10 @@ public class DataScopeService {
return EnumSet.of(DataScope.ALL); return EnumSet.of(DataScope.ALL);
} }
Set<DataScope> scopes = new LinkedHashSet<>(); Set<DataScope> scopes = new LinkedHashSet<>();
// 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认) // 1) 管理员两表授权(契约 §8 v1.5 优先级:管理员来源授权 > 身份默认)
if (hasScopeAllAnchor(user)) { DataScope granted = grantedByAdminTables(user, form);
scopes.add(DataScope.ALL); if (granted != null) {
scopes.add(granted);
} }
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽) // 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
int audience = form == null || form.getAudience() == null int audience = form == null || form.getAudience() == null
@@ -96,8 +100,8 @@ public class DataScopeService {
} }
/** /**
* 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里, * 一期可选的范围值(V28 的断言载体):{@code 任教班级} 的实现器 {@code selectable()=false},
* 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。 * T10 新增的 {@code SCHOOL} 同样 false——它不是筛选器选项,是授权结果(方案 §5.1 明示)。
*/ */
public Set<DataScope> selectableScopes() { public Set<DataScope> selectableScopes() {
Set<DataScope> result = new LinkedHashSet<>(); Set<DataScope> result = new LinkedHashSet<>();
@@ -119,9 +123,34 @@ public class DataScopeService {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope); throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
} }
/** 是否命中 fc:scope:all 权限锚点(只认 path 空节点,P47;见 FormModuleKeys#anchorCustomIds) */ /**
private boolean hasScopeAllAnchor(BoaUser user) { * 管理员两表在这张表单上应得的范围值(不给=null,交给矩阵)。
return FormModuleKeys.anchorCustomIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID); *
* <p>两张表的映射口径<em>逐字相同</em>(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定;
* 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。</p>
*/
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)、以及未知角色:兜底 // 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
return DataScope.SELF_SUBMITTED; 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);
}
} }
@@ -18,15 +18,18 @@ import java.util.Set;
* 因此不存在"菜单看得见、接口调不动"的两套口径):</p> * 因此不存在"菜单看得见、接口调不动"的两套口径):</p>
* <ul> * <ul>
* <li>定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats}, * <li>定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
* 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。</li> * 应用设置={@code /admin/app}(T10 方案 §3:字典管理行撤销、迁入应用设置页内 tab,
* <b>字典 API 摊这一行把关</b>;手册 §2.2 表 v1.5 起是五行版)。</li>
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、 * <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定, * 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定,
* 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。</li> * 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。</li>
* </ul> * </ul>
* *
* <p><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + customId 决定 * <p><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + 管理员两表决定
* ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行, * ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
* 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。</p> * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。
* <b>管理员两表不充当页面入口凭证</b>(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里
* 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。</p>
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -41,8 +44,11 @@ public class FormAdminGuard {
public static final String MODULE_DATA_MANAGE = "/admin/data"; public static final String MODULE_DATA_MANAGE = "/admin/data";
/** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */ /** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */
public static final String MODULE_STATS = "/admin/stats"; 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; private final ModuleTreeSource moduleTreeSource;
@@ -136,6 +142,46 @@ public class FormAdminGuard {
"无该模块权限(需任一):" + String.join(" 或 ", modulePaths)); "无该模块权限(需任一):" + String.join(" 或 ", modulePaths));
} }
/**
* 数据面<b>读出路径</b>的入口判定(T10 方案 §5.2):列表 / 筛选候选 / 统计 / 导出四条按表单寻址的
* 读接口用它,返回本次查询的租户域。与 {@link #requireAdmin} 的差别只有两处:
* <ol>
* <li><b>数据管理或统计任一模块行即可</b>(手册 §2.2 的"统计可并入数据管理,二选一"推广到整个
* 数据面——只授统计行的人不该在列表页拿到 403 而菜单里又确实有数据面);</li>
* <li><b>按表单归属域判</b>,于是基础表单({@code sch_id=0})在本校域下可读(T4 起就是这个口径,
* 本方法不收窄它),而他校表单与"表单不存在"同口径回答(uuid 不可枚举,契约 §1)。</li>
* </ol>
*
* <p><b>进来之后能看哪些行仍然只由 {@link DataScopeService} 决定</b>:管理员两表在这里不作入口凭证
* (T10 任务书 §0-4 守卫红线),它们只把范围从"仅本人提交"升到"全部/本校提交"。</p>
*
* <p>详情面(按 answerId 寻址)入口时还没有表单对象,沿用 {@link #requireAdminAny} 取域 +
* 构造器 {@code visible()} 的行级把关——同一份范围规格,可见集与列表必然同集(V27 的断言形态)。</p>
*/
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 集合(数据范围解析与调试取证用) */ /** 会话用户被授权的模块行 path 集合(数据范围解析与调试取证用) */
public Set<String> authorizedPaths(BoaUser user) { public Set<String> authorizedPaths(BoaUser user) {
return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user)); return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user));
@@ -229,6 +229,18 @@ public class FormDefService {
return form; return form;
} }
/**
* 数据面读接口<b>入口把关</b>用的表单定位(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) */ /** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
@@ -8,12 +8,12 @@ import java.util.List;
/** /**
* 会话用户在本应用下的<b>已授权模块树</b>来源(方案 §6.1 第②层,权限模型的唯一权威载体)。 * 会话用户在本应用下的<b>已授权模块树</b>来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
* *
* <p>T4 起两类判断都从这棵树出,<b>共用同一个接口</b>(执行手册 §5 P21/P30 裁决落地口径):</p> * <p>本树回答<b>管理权</b>一类问题(执行手册 §5 P21/P30 裁决落地口径):定义管理/数据管理/统计/
* <ul> * 应用设置=被授权对应模块行(path 命中,手册 §2.2 表 v1.5 五行版)。</p>
* <li><b>管理权</b>:定义管理/数据管理/统计/字典管理=被授权对应模块行(path 命中,手册 §2.2 表);</li> *
* <li><b>数据范围</b>:{@code fc:scope:all} 授权锚点=这棵树里 path 空、customId 为该值的纯权限位 * <p><b>T10 起数据范围不再从这棵树出</b>:{@code customId} 权限锚点协议退役(管理员体系方案 §7),
* (契约 §8 扩展协议)。</li> * 范围升级改由本库的管理员两表提供({@link AdminScopeSource})。path 空的锚点形态本身仍存在于
* </ul> * <b>快应用面</b>(导入/导出按钮,前端 {@code modulePerms} 消费,T9/P76 产物),只是中心后端不再扫描它。</p>
* *
* <p>做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。 * <p>做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。
* 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责); * 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责);
@@ -24,6 +24,6 @@ import java.util.List;
*/ */
public interface ModuleTreeSource { public interface ModuleTreeSource {
/** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */ /** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */
List<BoaModule> authorizedModules(BoaUser user); List<BoaModule> authorizedModules(BoaUser user);
} }
@@ -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;
/**
* 范围实现器:<b>本校提交</b>({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。
*
* <p>它是管理员两表在<b>基础表单(sch_id=0)</b>上的落地形态:校级/单表单管理员都不因授权而看见
* 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"——
* 那等于把本校范围读成全平台。</p>
*
* <p>{@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放",
* 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明
* 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。</p>
*/
@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<Answer> 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);
}
}
@@ -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 形态,天然限本校树")。
*
* <p><b>口径抄的是平台现行实现</b>:组树只取骨架并整棵剔除学生组({@code type=8}),成员来自 userclass
* 的教师列表({@code YschClassOpenApi.getSchTeaList})按所属组挂载——notice 的 {@code GetSchoolAll} 与
* flow-center 的 {@code /admin/user/teacherTree} 都是这一条。不走 {@code getGroupUsers}:那条会把
* 管理员等非教师账号混进树(flow-center 注释里写明了这是"选人数据不对"的根因)。</p>
*
* <p><b>教师列表拉不到时只回组骨架并打 warn</b>(同上先例):空组会被剪掉,前端看到的是"只有学校根节点"
* 的空树,比抛 500 更能在实拍里被发现;但组树本身取不到是硬失败(域都给不出,不能假装没人)。</p>
*/
@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<SchoolModels.Node> 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<Integer, SchoolModels.Node> groups = new LinkedHashMap<>();
List<BoaGroup> 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.<BoaGroup>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<Integer, SchoolModels.Node> groups, Long schId) {
List<SchTea> 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<Integer> 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<BoaGroup> 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<SchoolModels.Node> kept = new ArrayList<>();
for (SchoolModels.Node child : node.getChildren()) {
if (pruneEmptyGroups(child)) {
kept.add(child);
}
}
node.setChildren(kept);
return !kept.isEmpty();
}
}
@@ -29,7 +29,7 @@ public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
} }
@Override @Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) { public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("submitterId"), userId); return cb.equal(root.get("submitterId"), userId);
} }
} }
@@ -35,7 +35,7 @@ public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
} }
@Override @Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) { public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析 // 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)"); throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
} }
@@ -1,6 +1,6 @@
/** /**
* 表单中心服务域(T2 起填充):定义域(发布/升版/复制/字典/schema 校验器)、 * 表单中心服务域(T2 起填充):定义域(发布/升版/复制/字典/schema 校验器)、
* 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + fc:scope:all)、 * 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + 管理员两表)、
* 检索统计与导出、上传模块(FILE_PATH/FILE_WEB_PATH,值只存 {id,fileName,key})。 * 检索统计与导出、上传模块(FILE_PATH/FILE_WEB_PATH,值只存 {id,fileName,key})。
*/ */
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
@@ -1,5 +1,6 @@
package com.yunhai.formcenter.controller.api; 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.BoaModule;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -46,7 +47,7 @@ class FormBaseControllerTest {
@Test @Test
void toFrontendTree_shouldAlwaysEmitChildrenAsArray() { void toFrontendTree_shouldAlwaysEmitChildrenAsArray() {
BoaModule configuredLeaf = module(601, "102", "/admin/dicts", 1); BoaModule configuredLeaf = module(601, "102", "/admin/app", 1);
configuredLeaf.setChildren(new ArrayList<>()); configuredLeaf.setChildren(new ArrayList<>());
BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null
@@ -60,11 +61,11 @@ class FormBaseControllerTest {
@Test @Test
void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() { void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() {
// 数据范围权限锚点 fc:scope:all(契约 §8)就是非数字 customId:它 path 为空、不进导航, // 手册 §2.2 v1.5 起中心五行的 customId 恒为数字(200~204),非数字只是上游配错时的兜底:
// 只由后端扫描授权集合消费,这里只保证映射不炸、modId 退回主键。 // 映射不炸、modId 退回主键、path 空行给空串(前端导航按 path 空跳过,快应用按钮锚点同形态)
BoaModule scopeAnchor = module(701, "fc:scope:all", null, 1); BoaModule misconfigured = module(701, "not-a-number", null, 1);
List<Map<String, Object>> tree = FormBaseController.toFrontendTree(Collections.singletonList(scopeAnchor)); List<Map<String, Object>> tree = FormBaseController.toFrontendTree(Collections.singletonList(misconfigured));
assertEquals(701, tree.get(0).get("modId"), "custom_id 非数字时回退模块主键 id"); assertEquals(701, tree.get(0).get("modId"), "custom_id 非数字时回退模块主键 id");
assertEquals("", tree.get(0).get("page"), "path 缺失应给空串而非 null(前端字符串比较不判空)"); assertEquals("", tree.get(0).get("page"), "path 缺失应给空串而非 null(前端字符串比较不判空)");
@@ -90,14 +91,23 @@ class FormBaseControllerTest {
BoaUser user = new BoaUser(); BoaUser user = new BoaUser();
user.setId(10133); user.setId(10133);
List<Map<String, Object>> tree = new FormBaseController().getUserMod(user, "honor"); List<Map<String, Object>> tree = controller().getUserMod(user, "honor");
assertTrue(tree.isEmpty(), "H4(UserBase appCode 参数)未上线,目标应用一律空树"); assertTrue(tree.isEmpty(), "H4(UserBase appCode 参数)未上线,目标应用一律空树");
} }
@Test @Test
void getUserMod_shouldReturnEmptyTreeWithoutSessionUser() { 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.<BoaModule>emptyList()), null);
} }
private static BoaModule module(Integer id, String customId, String path, Integer level) { private static BoaModule module(Integer id, String customId, String path, Integer level) {
@@ -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;自验项 <b>V45</b> 与 <b>V46</b> 的守卫半边)。
*
* <p>断言的是写侧三条规则本身:uk 幂等、防呆(最后一员不可删)、跨校拒绝。入口凭证的判定
* 走 {@link FormAdminGuard}(本类不启 MockMvc,与 T2~T9 的服务级 IT 同一取证形态),
* 真会话的"授权→登录→菜单→数据范围→导出"端到端在遗留 #7 真会话轮补跑。</p>
*/
@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<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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<AdminModels.AdminView> 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;
}
}
@@ -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;自验项 <b>V43 / V44 / V47</b>)。
*
* <p>取证重点与 {@code DataScopeServiceTest} 互补:单测证"解析出哪个集合",这里证
* <b>集合落成的 SQL 谓词真的少读了行</b>——包括 SCHOOL 谓词把 {@code submitter_sch_id} 为 NULL 的
* 存量行挡在外面(方案 §4 的 fail-closed 半边),以及导出与列表同集(V27 红线的 T10 延伸)。</p>
*
* <p>数据集刻意含"基础表单(sch_id=0)+ 两校各提交一条":这是 {@code SCHOOL} 唯一能被单独分辨出来的
* 形状(本校表单上升级出的是 ALL,与租户域同向;只有平台域表单的读开口才需要"本校提交"这一档)。</p>
*/
@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<String> 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<String> 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<String> 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<String> 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<String> 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<DataScope> 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;
}
}
@@ -140,14 +140,14 @@ class AnswerReadIT extends AnswerTestSupport {
} }
@Test @Test
@DisplayName("V42 扩展位:给组授权 customId=fc:scope:all 的模块行后,该教师范围升级为全部(与矩阵同一条解析)") @DisplayName("V43 扩展位:教师进了 school_admin 后,本校表单范围升级为全部(与矩阵同一条解析)")
void scopeAllAnchorUpgradesTeacher() { void schoolAdminUpgradesTeacherOnOwnForm() {
ReadFixture fixture = new ReadFixture(this); ReadFixture fixture = new ReadFixture(this);
ReadFixture.Rows rows = fixture.seed(manager()); ReadFixture.Rows rows = fixture.seed(manager());
BoaUser teacher = fixture.teacher1(); BoaUser teacher = fixture.teacher1();
assertThat(visibleIds(fixture, teacher, rows.formAUuid)).containsExactly(rows.teacher1Self); 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)); ReadModels.ListView upgraded = readService.list(teacher, SCH, listRequest(rows.formAUuid));
assertThat(upgraded.getScopes()).containsExactly(DataScope.ALL); assertThat(upgraded.getScopes()).containsExactly(DataScope.ALL);
assertThat(upgraded.getRows()).hasSize(5); assertThat(upgraded.getRows()).hasSize(5);
@@ -1,9 +1,13 @@
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer; 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.AnswerModels;
import com.yunhai.formcenter.model.FormModels; import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.AnswerRepository; 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.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -62,6 +66,12 @@ abstract class AnswerTestSupport {
@Autowired @Autowired
protected InMemorySubjectDirectory subjects; protected InMemorySubjectDirectory subjects;
@Autowired
protected SchoolAdminRepository schoolAdminRows;
@Autowired
protected FormAdminRepository formAdminRows;
@BeforeEach @BeforeEach
void resetGateways() { void resetGateways() {
moduleTree.reset(); moduleTree.reset();
@@ -124,6 +134,31 @@ abstract class AnswerTestSupport {
return user; 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 */ /** 建表单 → 保存 schema → 发布,返回 uuid */
@@ -4,7 +4,6 @@ import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums; import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope; import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException; 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.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName; 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; import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** /**
* 数据范围解析单测(自验项 <b>V28</b> 全部 + <b>V42</b> 的"身份默认矩阵/customId 升级/回落"三半边)。 * 数据范围解析单测(自验项 <b>V28</b> 全部 + <b>V42</b> 的"身份默认矩阵/来源升级/回落"三半边 +
* <b>V43/V44</b> 的解析半边)。
* *
* <p>矩阵(契约 §8)在这里逐格断言;"受限身份的统计/导出 ⊆ 列表可见集"那半边是接口级事实, * <p>矩阵(契约 §8)在这里逐格断言;"受限身份的统计/导出 ⊆ 列表可见集"那半边是接口级事实,
* 在 {@code AnswerReadIT} 打真库断言。桩只顶掉模块树来源(UserBase 事实源), * 在 {@code AnswerReadIT}/{@code AdminScopeIT} 打真库断言。桩只顶掉两表来源(本库事实源),
* 解析规则本身全部走生产代码。</p> * 解析规则本身全部走生产代码。</p>
*
* <p>T10 改版:原"customId 锚点升级"两例(V42 扩展位 / P47 只认 path 空行)随锚点协议退役删除,
* 同一位置改由管理员两表用例承担(V43/V44)。</p>
*/ */
@DisplayName("DataScopeService - 契约 §8 矩阵与 customId 升级(V28/V42)") @DisplayName("DataScopeService - 契约 §8 矩阵与管理员两表升级(V28/V42/V43/V44)")
class DataScopeServiceTest { class DataScopeServiceTest {
private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode(); private static final int STUDENT_FACING = FormEnums.Audience.STUDENT.getCode();
private static final int TEACHER_FACING = FormEnums.Audience.TEACHER.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 InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource();
private final DataScopeService service = new DataScopeService(tree, Arrays.asList( private final DataScopeService service = new DataScopeService(implementors(), admins);
new AllScopeImplementor(), new SelfSubmittedScopeImplementor(),
new AboutMeScopeImplementor(), new TeachingClassScopeImplementor()));
// ==================== V42:身份默认矩阵 ==================== // ==================== V42:身份默认矩阵 ====================
@@ -43,33 +46,39 @@ class DataScopeServiceTest {
@DisplayName("V42 矩阵:管理员(7)/校领导(2)=全部,两种面向人群都是全部") @DisplayName("V42 矩阵:管理员(7)/校领导(2)=全部,两种面向人群都是全部")
void adminAndLeaderSeeAll() { void adminAndLeaderSeeAll() {
for (int roleId : Arrays.asList(7, 2)) { for (int roleId : Arrays.asList(7, 2)) {
assertThat(service.resolve(user(roleId), form(STUDENT_FACING))).containsExactly(DataScope.ALL); assertThat(service.resolve(user(roleId), form(SCH, "f-own", STUDENT_FACING)))
assertThat(service.resolve(user(roleId), form(TEACHER_FACING))).containsExactly(DataScope.ALL); .containsExactly(DataScope.ALL);
assertThat(service.resolve(user(roleId), form(SCH, "f-own", TEACHER_FACING)))
.containsExactly(DataScope.ALL);
} }
} }
@Test @Test
@DisplayName("V42 矩阵:教师(1)×面向学生=一期生效仅本人提交(任教班级实现器缺席);×面向教师=仅本人提交") @DisplayName("V42 矩阵:教师(1)×面向学生=一期生效仅本人提交(任教班级实现器缺席);×面向教师=仅本人提交")
void teacherScopes() { void teacherScopes() {
assertThat(service.resolve(user(1), form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED); assertThat(service.resolve(user(1), form(SCH, "f-own", STUDENT_FACING)))
assertThat(service.resolve(user(1), form(TEACHER_FACING))).containsExactly(DataScope.SELF_SUBMITTED); .containsExactly(DataScope.SELF_SUBMITTED);
assertThat(service.resolve(user(1), form(SCH, "f-own", TEACHER_FACING)))
.containsExactly(DataScope.SELF_SUBMITTED);
} }
@Test @Test
@DisplayName("V42 矩阵:学生(3)×面向学生=关于我(老师代填的那条也可见);×面向教师按最保守兜底") @DisplayName("V42 矩阵:学生(3)×面向学生=关于我(老师代填的那条也可见);×面向教师按最保守兜底")
void studentScopes() { void studentScopes() {
assertThat(service.resolve(user(3), form(STUDENT_FACING))).containsExactly(DataScope.ABOUT_ME); assertThat(service.resolve(user(3), form(SCH, "f-own", STUDENT_FACING)))
assertThat(service.resolve(user(3), form(TEACHER_FACING))).containsExactly(DataScope.SELF_SUBMITTED); .containsExactly(DataScope.ABOUT_ME);
assertThat(service.resolve(user(3), form(SCH, "f-own", TEACHER_FACING)))
.containsExactly(DataScope.SELF_SUBMITTED);
} }
@Test @Test
@DisplayName("V42 矩阵:家长(4)/工作号(6)/局领导(5)=回落仅本人提交(两种人群都是)") @DisplayName("V42 矩阵:家长(4)/工作号(6)/局领导(5)=回落仅本人提交(两种人群都是)")
void peripheralRolesFallBackToSelf() { void peripheralRolesFallBackToSelf() {
for (int roleId : Arrays.asList(4, 6, 5)) { 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 + " 面向学生") .as("roleId=" + roleId + " 面向学生")
.containsExactly(DataScope.SELF_SUBMITTED); .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 + " 面向教师") .as("roleId=" + roleId + " 面向教师")
.containsExactly(DataScope.SELF_SUBMITTED); .containsExactly(DataScope.SELF_SUBMITTED);
} }
@@ -81,7 +90,8 @@ class DataScopeServiceTest {
BoaUser nobody = new BoaUser(); BoaUser nobody = new BoaUser();
nobody.setId(20001); nobody.setId(20001);
nobody.setCurrentRole(role(9, 990301)); 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 @Test
@@ -92,73 +102,131 @@ class DataScopeServiceTest {
user.setType(0); user.setType(0);
user.setRoleIds(Arrays.asList(1, 3)); user.setRoleIds(Arrays.asList(1, 3));
user.setCurrentRole(role(1, 990301)); user.setCurrentRole(role(1, 990301));
Set<DataScope> scopes = service.resolve(user, form(STUDENT_FACING)); Set<DataScope> scopes = service.resolve(user, form(SCH, "f-own", STUDENT_FACING));
assertThat(scopes).containsExactlyInAnyOrder(DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME); assertThat(scopes).containsExactlyInAnyOrder(DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME);
// 其中一格是"全部"时收敛为全部(其余都是它的子集,留着只让读路径多写 OR) // 其中一格是"全部"时收敛为全部(其余都是它的子集,留着只让读路径多写 OR)
user.setRoleIds(Arrays.asList(1, 3, 7)); 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 @Test
@DisplayName("V42 扩展:授权 customId=fc:scope:all 的模块行后,该组成员范围升级为全部") @DisplayName("V43 校级管理员:本校表单=全部(含本校快应用表单——解析不看 app_code)")
void customIdAnchorUpgradesToAll() { void schoolAdminSeesAllOnOwnSchoolForms() {
BoaUser teacher = user(1); BoaUser teacher = user(1);
assertThat(service.resolve(teacher, form(STUDENT_FACING))).containsExactly(DataScope.SELF_SUBMITTED); assertThat(service.resolve(teacher, form(SCH, "f-own", STUDENT_FACING)))
.as("未进表时仍是矩阵默认")
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」,导航行上的误配值不参与范围升级")
.containsExactly(DataScope.SELF_SUBMITTED); .containsExactly(DataScope.SELF_SUBMITTED);
// 对照:同一用户同一值,改挂到 path 空行即升级——证明失败面只在"行形态",不是扫描整体失效 admins.grantSchoolAdmin(SCH, teacher.getId());
misconfigured.grantCustomId(teacher.getId(), DataScopeService.SCOPE_ALL_CUSTOM_ID); assertThat(service.resolve(teacher, form(SCH, "f-quick", STUDENT_FACING)))
assertThat(serviceWith(misconfigured).resolve(teacher, form(STUDENT_FACING)))
.containsExactly(DataScope.ALL); .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 @Test
@DisplayName("V28:契约 §8 四值都在枚举里;一期可选集合只有三个(任教班级缺席)") @DisplayName("V43 会话域跟随:同一个人是 A 校校级管理员,会话切到 B 校后 A 校授权不跟着过来")
void teachingClassIsInEnumButNotSelectable() { 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( 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( assertThat(service.selectableScopes()).containsExactlyInAnyOrder(
DataScope.ALL, DataScope.SELF_SUBMITTED, DataScope.ABOUT_ME); 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 @Test
@@ -166,7 +234,7 @@ class DataScopeServiceTest {
void noCombinationResolvesToTeachingClass() { void noCombinationResolvesToTeachingClass() {
for (int roleId : new int[]{1, 2, 3, 4, 5, 6, 7}) { for (int roleId : new int[]{1, 2, 3, 4, 5, 6, 7}) {
for (int audience : new int[]{STUDENT_FACING, TEACHER_FACING}) { 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) .as("roleId=" + roleId + " audience=" + audience)
.doesNotContain(DataScope.TEACHING_CLASS); .doesNotContain(DataScope.TEACHING_CLASS);
} }
@@ -177,11 +245,19 @@ class DataScopeServiceTest {
@DisplayName("V28:任教班级实现器被误用时明确报错(1.5 开放前不存在可用谓词),不会静默按全部放行") @DisplayName("V28:任教班级实现器被误用时明确报错(1.5 开放前不存在可用谓词),不会静默按全部放行")
void teachingClassImplementorRefusesToBuildPredicate() { void teachingClassImplementorRefusesToBuildPredicate() {
assertThatThrownBy(() -> service.implementorOf(DataScope.TEACHING_CLASS) assertThatThrownBy(() -> service.implementorOf(DataScope.TEACHING_CLASS)
.rowPredicate(null, null, 1)) .rowPredicate(null, null, 1, SCH))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("一期未开放"); .hasMessageContaining("一期未开放");
} }
@Test
@DisplayName("V43 半边:本校提交实现器给不出会话学校时明确拒绝(不静默退化成全校)")
void schoolImplementorRefusesWithoutSessionSchool() {
assertThatThrownBy(() -> service.implementorOf(DataScope.SCHOOL).rowPredicate(null, null, 1, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("需要会话学校");
}
// ==================== 其它 ==================== // ==================== 其它 ====================
@Test @Test
@@ -190,62 +266,67 @@ class DataScopeServiceTest {
BoaUser root = new BoaUser(); BoaUser root = new BoaUser();
root.setId(1); root.setId(1);
root.setType(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 @Test
@DisplayName("未登录不解析范围(拒绝,而不是给一个兜底范围)") @DisplayName("未登录不解析范围(拒绝,而不是给一个兜底范围)")
void requiresSession() { void requiresSession() {
assertThatThrownBy(() -> service.resolve(null, form(STUDENT_FACING))) assertThatThrownBy(() -> service.resolve(null, form(SCH, "f-own", STUDENT_FACING)))
.isInstanceOf(BizException.class).hasMessageContaining("未登录"); .isInstanceOf(BizException.class).hasMessageContaining("未登录");
assertThatThrownBy(() -> service.resolve(new BoaUser(), form(STUDENT_FACING))) assertThatThrownBy(() -> service.resolve(new BoaUser(), form(SCH, "f-own", STUDENT_FACING)))
.hasMessageContaining("未登录"); .hasMessageContaining("未登录");
} }
@Test @Test
@DisplayName("表单为 null(如平台级读上下文缺失)按面向学生列解析,不抛空指针") @DisplayName("表单为 null(如平台级读上下文缺失)按面向学生列解析,不抛空指针、也不产生两表升级")
void nullFormUsesStudentColumn() { 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(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 @Test
@DisplayName("实现器注册表按枚举取用;未注册的范围视为配置缺失直接报错(不静默当全部)") @DisplayName("实现器注册表按枚举取用;未注册的范围视为配置缺失直接报错(不静默当全部)")
void unknownScopeFailsLoud() { void unknownScopeFailsLoud() {
DataScopeService partial = new DataScopeService(tree, DataScopeService partial = new DataScopeService(
new ArrayList<>(Arrays.asList(new AllScopeImplementor()))); new ArrayList<>(Collections.singletonList(new AllScopeImplementor())), admins);
assertThatThrownBy(() -> partial.implementorOf(DataScope.ABOUT_ME)) assertThatThrownBy(() -> partial.implementorOf(DataScope.ABOUT_ME))
.isInstanceOf(BizException.class).hasMessageContaining("缺少实现器"); .isInstanceOf(BizException.class).hasMessageContaining("缺少实现器");
} }
// ==================== 构造小工具 ==================== // ==================== 构造小工具 ====================
/** 与生产装配一致的注册表:T10 起五值五实现器(任教班级仍是占位、SCHOOL 新增) */
private static List<AnswerScopeImplementor> implementors() {
return Arrays.asList(new AllScopeImplementor(), new SelfSubmittedScopeImplementor(),
new AboutMeScopeImplementor(), new TeachingClassScopeImplementor(), new SchoolScopeImplementor());
}
private BoaUser user(int roleId) { private BoaUser user(int roleId) {
BoaUser user = new BoaUser(); BoaUser user = new BoaUser();
user.setId(20000 + roleId); user.setId(20000 + roleId);
user.setType(0); user.setType(0);
user.setRoleIds(Collections.singletonList(roleId)); user.setRoleIds(Collections.singletonList(roleId));
user.setCurrentRole(role(roleId, 990301)); user.setCurrentRole(role(roleId, (int) SCH));
return user; return user;
} }
private BoaRoleInfo role(Integer roleId, int schId) { private BoaRoleInfo role(Integer roleId, Integer schId) {
BoaRoleInfo info = new BoaRoleInfo(); BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId); info.setRoleId(roleId);
info.setSchId(schId); info.setSchId(schId);
return info; return info;
} }
private Form form(int audience) { private Form form(long schId, String uuid, int audience) {
Form form = new Form(); Form form = new Form();
form.setSchId(schId);
form.setUuid(uuid);
form.setAudience(audience); form.setAudience(audience);
return form; return form;
} }
/** 换一个模块树桩组同一个解析器(实现器集合与生产装配一致:四值四个实现器) */
private DataScopeService serviceWith(InMemoryModuleTreeSource source) {
return new DataScopeService(source, Arrays.asList(
new AllScopeImplementor(), new SelfSubmittedScopeImplementor(),
new AboutMeScopeImplementor(), new TeachingClassScopeImplementor()));
}
} }
@@ -1,5 +1,6 @@
package com.yunhai.formcenter.service; package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo; 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; import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** /**
* 管理权闸门单测(执行手册 §5 <b>P21 裁决的 T4 到期替换</b>:授权模块集口径,roleId 硬编码整体移除)。 * 管理权闸门单测(执行手册 §5 <b>P21 裁决的 T4 到期替换</b>:授权模块集口径,roleId 硬编码整体移除;
* T10 再改:字典管理行撤、应用设置行接手 + 数据面入口判定 {@code requireDataView})。
* *
* <p>口径:能进哪个管理面=被授权哪个模块行(手册 §2.2 表:定义管理 {@code /admin/forms}、 * <p>口径:能进哪个管理面=被授权哪个模块行(手册 §2.2 表 v1.5 五行版:定义管理 {@code /admin/forms}、
* 数据管理 {@code /admin/data}、统计 {@code /admin/stats}、字典管理 {@code /admin/dicts}); * 数据管理 {@code /admin/data}、统计 {@code /admin/stats}、应用设置 {@code /admin/app}、我的填写
* 超管({@code type==1},UserBase 用户类型不是 roleId)仍可跨校且唯一可管 {@code sch_id=0} 平台级行 * {@code /my});超管({@code type==1},UserBase 用户类型不是 roleId)仍可跨校且唯一可管
* (方案 §6.5"超管不受单校开通约束")。</p> * {@code sch_id=0} 平台级行(方案 §6.5"超管不受单校开通约束")。</p>
* *
* <p>桩={@link ModuleTreeSource} 的 lambda——本类不启 Spring、不连库,验证的是"给定授权集合, * <p>桩={@link ModuleTreeSource} 的 lambda——本类不启 Spring、不连库,验证的是"给定授权集合,
* 判定对不对"。真实树来自 UserBase 的链路在 T9 联调验。</p> * 判定对不对"。真实树来自 UserBase 的链路在 T9 联调验。</p>
*/ */
@DisplayName("FormAdminGuard - 授权模块集口径(P21 替换后)") @DisplayName("FormAdminGuard - 授权模块集口径与数据面入口判定(P21/T10)")
class FormAdminGuardTest { class FormAdminGuardTest {
private static final long SCH = 880001L; private static final long SCH = 880001L;
private static final long OTHER_SCH = 880002L;
@Test @Test
@DisplayName("被授权数据管理模块行=可进数据面;同 roleId 但未授权=拒(管理权不再来自角色)") @DisplayName("被授权数据管理模块行=可进数据面;同 roleId 但未授权=拒(管理权不再来自角色)")
@@ -51,13 +54,13 @@ class FormAdminGuardTest {
} }
@Test @Test
@DisplayName("四个模块行互不通用:只有定义管理权的人调字典面被拒(口径=手册 §2.2 一行一权)") @DisplayName("四个模块行互不通用:只有定义管理权的人调应用设置面(含字典 API)被拒(口径=手册 §2.2 一行一权)")
void modulesAreNotInterchangeable() { void modulesAreNotInterchangeable() {
FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_FORM_DESIGN); FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_FORM_DESIGN);
assertThatCode(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_FORM_DESIGN)) assertThatCode(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_FORM_DESIGN))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_DICT)) assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_APP_SETTINGS))
.hasMessageContaining(FormAdminGuard.MODULE_DICT); .hasMessageContaining(FormAdminGuard.MODULE_APP_SETTINGS);
assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_STATS)) assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_STATS))
.hasMessageContaining(FormAdminGuard.MODULE_STATS); .hasMessageContaining(FormAdminGuard.MODULE_STATS);
} }
@@ -71,18 +74,18 @@ class FormAdminGuardTest {
assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE) assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE)
.requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_DICT) assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_APP_SETTINGS)
.requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS, .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
FormAdminGuard.MODULE_DATA_MANAGE)) FormAdminGuard.MODULE_DATA_MANAGE))
.isInstanceOf(BizException.class).hasMessageContaining("需任一"); .isInstanceOf(BizException.class).hasMessageContaining("需任一");
} }
@Test @Test
@DisplayName("path 空的权限锚点(customId=fc:scope:all)不给管理权——它是数据范围升级位,不是模块行") @DisplayName("path 空的权限位(快应用按钮锚点那种行)不给中心管理权——中心守卫只认页面路由行")
void scopeAnchorIsNotAModuleRow() { void blankPathAnchorIsNotAModuleRow() {
FormAdminGuard guard = new FormAdminGuard(user -> { FormAdminGuard guard = new FormAdminGuard(user -> {
BoaModule anchor = new BoaModule(); BoaModule anchor = new BoaModule();
anchor.setCustomId(DataScopeService.SCOPE_ALL_CUSTOM_ID); anchor.setCustomId("303"); // 快应用面的"导入"锚点形态:path 空 + 数字 customId
anchor.setChildren(new ArrayList<BoaModule>()); anchor.setChildren(new ArrayList<BoaModule>());
List<BoaModule> rows = new ArrayList<>(); List<BoaModule> rows = new ArrayList<>();
rows.add(anchor); rows.add(anchor);
@@ -94,11 +97,11 @@ class FormAdminGuardTest {
} }
@Test @Test
@DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有字典管理没有;未授权没有") @DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有应用设置没有;未授权没有")
void canManageFollowsModuleSet() { void canManageFollowsModuleSet() {
assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE).canManage(schoolUser(1))).isTrue(); assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE).canManage(schoolUser(1))).isTrue();
assertThat(guardWith(FormAdminGuard.MODULE_FORM_DESIGN).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(); assertThat(guardWith().canManage(schoolUser(7))).isFalse();
} }
@@ -120,7 +123,7 @@ class FormAdminGuardTest {
BoaUser root = new BoaUser(); BoaUser root = new BoaUser();
root.setId(1); root.setId(1);
root.setType(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); assertThat(guard.requireAdmin(root, SCH, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
root.setCurrentRole(role(3, (int) SCH)); root.setCurrentRole(role(3, (int) SCH));
assertThat(guard.requireAdmin(root, null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH); assertThat(guard.requireAdmin(root, null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
@@ -144,6 +147,8 @@ class FormAdminGuardTest {
.hasMessageContaining("未登录"); .hasMessageContaining("未登录");
assertThatThrownBy(() -> guard.requireAdminAny(anonymous, null, FormAdminGuard.MODULE_STATS)) assertThatThrownBy(() -> guard.requireAdminAny(anonymous, null, FormAdminGuard.MODULE_STATS))
.hasMessageContaining("未登录"); .hasMessageContaining("未登录");
assertThatThrownBy(() -> guard.requireDataView(anonymous, null, form(SCH, "f-a")))
.hasMessageContaining("未登录");
assertThat(guard.canManage(null)).isFalse(); assertThat(guard.canManage(null)).isFalse();
assertThat(guard.canManage(anonymous)).isFalse(); assertThat(guard.canManage(anonymous)).isFalse();
} }
@@ -165,7 +170,67 @@ class FormAdminGuardTest {
.doesNotThrowAnyException(); .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) { private static FormAdminGuard guardWith(String... paths) {
return new FormAdminGuard(user -> { return new FormAdminGuard(user -> {
List<BoaModule> rows = new ArrayList<>(); List<BoaModule> rows = new ArrayList<>();
@@ -194,4 +259,11 @@ class FormAdminGuardTest {
info.setSchId(schId); info.setSchId(schId);
return info; return info;
} }
private static Form form(long schId, String uuid) {
Form form = new Form();
form.setSchId(schId);
form.setUuid(uuid);
return form;
}
} }
@@ -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:本地单测不连库,两表授权注入这里取证)。
*
* <p>缺省=谁都不在表里(fail-closed,与生产一致);需要授权的用例显式 {@code grant}。
* 集成测试不用本桩——两表就是本库的表,直接插真行更能证 uk 幂等与谓词生效。</p>
*/
public class InMemoryAdminScopeSource implements AdminScopeSource {
private final Map<Long, Set<Integer>> schoolAdmins = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> 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<Integer> ids = schId == null ? null : schoolAdmins.get(schId);
return ids != null && userId != null && ids.contains(userId);
}
@Override
public boolean isFormAdmin(String formUuid, Integer userId) {
Set<Integer> ids = formUuid == null ? null : formAdmins.get(formUuid);
return ids != null && userId != null && ids.contains(userId);
}
}
@@ -10,12 +10,14 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的授权模块集口径 * {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的管理权口径注入这里取证,
* 与契约 §8 的 customId 扫描都注入这里取证,真实链路 T9 联调验)。 * 真实链路 T9 联调验)。
* *
* <p>建模的是 UserBase 侧的两种模块行(手册 §2.2):{@code path} 非空=页面路由行、 * <p>建模的是 UserBase 侧的页面路由行(手册 §2.2:{@code path} 逐字对上 front 路由)。缺省<em>不给任何
* {@code path} 空 + {@code customId}=纯权限锚点。缺省<em>不给任何模块</em>——与生产侧 * 模块</em>——与生产侧 fail-closed 同向(无授权=无管理权、范围回落到仅本人提交),需要权限的用例显式授权。</p>
* fail-closed 同向(无授权=无管理权、范围回落仅本人提交),需要权限的用例显式授权。</p> *
* <p>T10 起本桩只剩 path 一种形态:{@code customId} 权限锚点协议退役,数据范围升级改由管理员两表提供
* ({@link InMemoryAdminScopeSource})。</p>
*/ */
public class InMemoryModuleTreeSource implements ModuleTreeSource { public class InMemoryModuleTreeSource implements ModuleTreeSource {
@@ -25,23 +27,10 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource {
public void grantPaths(Integer userId, String... paths) { public void grantPaths(Integer userId, String... paths) {
List<BoaModule> rows = rows(userId); List<BoaModule> rows = rows(userId);
for (String path : paths) { 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 取证用):一条<em>会进导航</em>的页面路由行同时挂了 customId。
* 契约 §8 的锚点行形态是「path 空 + customId」,这种行不该被当成锚点消费。
*/
public void grantPathWithCustomId(Integer userId, String path, String customId) {
rows(userId).add(row(path, customId));
}
/** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */ /** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */
public void clear(Integer userId) { public void clear(Integer userId) {
byUser.remove(userId); byUser.remove(userId);
@@ -52,16 +41,6 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource {
byUser.clear(); byUser.clear();
} }
public List<String> pathsOf(Integer userId) {
List<String> paths = new ArrayList<>();
for (BoaModule module : byUser.getOrDefault(userId, Collections.<BoaModule>emptyList())) {
if (module.getPath() != null) {
paths.add(module.getPath());
}
}
return paths;
}
@Override @Override
public List<BoaModule> authorizedModules(BoaUser user) { public List<BoaModule> authorizedModules(BoaUser user) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
@@ -76,11 +55,10 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource {
return byUser.computeIfAbsent(userId, key -> Collections.synchronizedList(new ArrayList<>())); 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(); BoaModule module = new BoaModule();
module.setPath(path); module.setPath(path);
module.setCustomId(customId); module.setName("模块 " + path);
module.setName(path == null ? "权限锚点 " + customId : "模块 " + path);
module.setChildren(new ArrayList<BoaModule>()); module.setChildren(new ArrayList<BoaModule>());
return module; return module;
} }
@@ -89,15 +67,4 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource {
public void grantManageDefaults(Integer userId) { public void grantManageDefaults(Integer userId) {
grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN); grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN);
} }
/** 树形下发形态(有子节点):验证扫描是递归的,不只读顶层 */
public void grantNested(Integer userId, String parentPath, List<BoaModule> 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);
}
} }