- 新增 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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user