From dc76574118fade9cce8ff53e2c77bf895652b7e7 Mon Sep 17 00:00:00 2001
From: chenxing
Date: Thu, 15 Oct 2026 20:25:03 +0800
Subject: [PATCH] =?UTF-8?q?feat(t10-admin):=20=E7=AE=A1=E7=90=86=E5=91=98?=
=?UTF-8?q?=E4=BD=93=E7=B3=BB=E2=80=94=E2=80=94=E6=A0=A1=E7=BA=A7=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=91=98+=E5=8D=95=E8=A1=A8=E5=8D=95=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=91=98+=E9=94=9A=E7=82=B9=E9=80=80=E5=BD=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 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 等
---
sql/h2_userbase_app_and_modules.sql | 55 ++--
sql/t10_admin_domain.sql | 64 ++++
.../formcenter/common/FormModuleKeys.java | 43 +--
.../controller/api/AdminDomainController.java | 102 ++++++
.../controller/api/FormAdminController.java | 14 +-
.../api/FormAnswerReadController.java | 25 +-
.../controller/api/FormBaseController.java | 27 +-
.../com/yunhai/formcenter/entity/Answer.java | 13 +
.../yunhai/formcenter/entity/FormAdmin.java | 44 +++
.../yunhai/formcenter/entity/SchoolAdmin.java | 42 +++
.../yunhai/formcenter/enums/FormEnums.java | 11 +-
.../yunhai/formcenter/model/AdminModels.java | 45 +++
.../yunhai/formcenter/model/ReadModels.java | 2 +
.../yunhai/formcenter/model/SchoolModels.java | 30 ++
.../repository/FormAdminRepository.java | 22 ++
.../repository/SchoolAdminRepository.java | 24 ++
.../service/AboutMeScopeImplementor.java | 2 +-
.../service/AdminDomainService.java | 191 ++++++++++++
.../formcenter/service/AdminScopeSource.java | 21 ++
.../service/AllScopeImplementor.java | 4 +-
.../formcenter/service/AnswerReadService.java | 4 +-
.../service/AnswerScopeImplementor.java | 6 +-
.../service/AnswerScopeQueryBuilder.java | 4 +-
.../formcenter/service/AnswerService.java | 3 +
.../formcenter/service/DataScopeService.java | 75 +++--
.../formcenter/service/FormAdminGuard.java | 56 +++-
.../formcenter/service/FormDefService.java | 12 +
.../formcenter/service/ModuleTreeSource.java | 14 +-
.../service/SchoolScopeImplementor.java | 45 +++
.../service/SchoolUserTreeService.java | 161 ++++++++++
.../SelfSubmittedScopeImplementor.java | 2 +-
.../TeachingClassScopeImplementor.java | 2 +-
.../formcenter/service/package-info.java | 2 +-
.../api/FormBaseControllerTest.java | 24 +-
.../formcenter/service/AdminDomainIT.java | 215 +++++++++++++
.../formcenter/service/AdminScopeIT.java | 295 ++++++++++++++++++
.../formcenter/service/AnswerReadIT.java | 6 +-
.../formcenter/service/AnswerTestSupport.java | 35 +++
.../service/DataScopeServiceTest.java | 249 ++++++++++-----
.../service/FormAdminGuardTest.java | 106 ++++++-
.../service/InMemoryAdminScopeSource.java | 45 +++
.../service/InMemoryModuleTreeSource.java | 53 +---
42 files changed, 1921 insertions(+), 274 deletions(-)
create mode 100644 sql/t10_admin_domain.sql
create mode 100644 src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java
create mode 100644 src/main/java/com/yunhai/formcenter/entity/FormAdmin.java
create mode 100644 src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java
create mode 100644 src/main/java/com/yunhai/formcenter/model/AdminModels.java
create mode 100644 src/main/java/com/yunhai/formcenter/model/SchoolModels.java
create mode 100644 src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java
create mode 100644 src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java
create mode 100644 src/main/java/com/yunhai/formcenter/service/AdminDomainService.java
create mode 100644 src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java
create mode 100644 src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java
create mode 100644 src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java
create mode 100644 src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java
create mode 100644 src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
create mode 100644 src/test/java/com/yunhai/formcenter/service/InMemoryAdminScopeSource.java
diff --git a/sql/h2_userbase_app_and_modules.sql b/sql/h2_userbase_app_and_modules.sql
index d4b4e86..e593b1a 100644
--- a/sql/h2_userbase_app_and_modules.sql
+++ b/sql/h2_userbase_app_and_modules.sql
@@ -1,9 +1,10 @@
-- ============================================================================
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
--- 编制:Qoder(T9 联调轮)|日期:2026-10-15
--- 依据:《表单中心执行手册》§2.2(中心应用六条模块行表 + 验证口径)、
--- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.4(customId=fc:scope:all)、
--- §6.5(中心=BUSINESS 应用,模块行与快应用模块行两棵树不混)
+-- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役)
+-- 依据:《表单中心执行手册》§2.2(中心应用 v1.5 五条模块行表 + 验证口径)、
+-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.5(中心=BUSINESS 应用,
+-- 模块行与快应用模块行两棵树不混)
+-- 《表单中心管理员体系方案-20261015》§3/§7(T10 改版:中心 6→5 行,customId 数据范围锚点退役)
-- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法)
--
-- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。
@@ -37,14 +38,17 @@ SET @operator = 0; -- create_user 占位(0=系统)
-- WHERE @center_app_id = 0;
-- ----------------------------------------------------------------------------
--- 步骤 1:中心应用六条模块行(执行手册 §2.2 表;path 必须逐字等于 front/src/router/routes.ts)
+-- 步骤 1:中心应用五条模块行(执行手册 §2.2 v1.5 表;path 必须逐字等于 front/src/router/routes.ts)
-- ①表单设计 /admin/forms ②数据管理 /admin/data ③统计 /admin/stats
--- ④字典管理 /admin/dicts ⑤我的填写 /my(全员) ⑥数据全量【path 留空 + customId=fc:scope:all】
--- 第⑥行=纯权限锚点:导航不渲染、后端 DataScopeService 只扫 path 空节点的 customId(P47 收紧后口径),
--- 授权给哪个组,该组的数据范围即升级为"全部"(方案 §6.4)。
--- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail)
+-- ④应用设置 /admin/app(页内 tab:角色设置/字典管理,字典 API 摊这一行把关) ⑤我的填写 /my(全员)
+-- v1.5 改版(T10,2026-10-15 用户裁决):
+-- · 原「字典管理 /admin/dicts」独立行撤销,迁入应用设置页内 tab(不占 modId、不进导航);
+-- · 原「数据全量」path 空锚点行撤销——UserBase 侧 customId 数据范围协议退役,
+-- 升级载体改为中心自建的管理员两表(school_admin / form_admin,见《管理员体系方案》§4/§5.1)。
+-- **中心应用自此不再有 path 空行**;前端 menuNav 的"path 空跳过"规则保留给快应用面的按钮锚点。
+-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail、/admin/app 的页内 tab)
-- **不建模块行**(手册 §2.2 注记 + P63/P77):不占 modId、不进导航,前端按 meta.parentPath 归位。
--- custom_id 用 200~205 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId)
+-- custom_id 用 200~204 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId)
-- ----------------------------------------------------------------------------
INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
@@ -53,10 +57,8 @@ SELECT * FROM (
'/admin/forms' AS path, 'word' AS icon, 1 AS level, 200 AS sort_no, @operator AS create_user, NOW() AS create_time
UNION ALL SELECT @center_app_id, NULL, '数据管理', '201', '/admin/data', 'category', 1, 201, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '统计', '202', '/admin/stats','tongji', 1, 202, @operator, NOW()
- UNION ALL SELECT @center_app_id, NULL, '字典管理', '203', '/admin/dicts','zidingyi', 1, 203, @operator, NOW()
+ UNION ALL SELECT @center_app_id, NULL, '应用设置', '203', '/admin/app', 'zidingyi', 1, 203, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '我的填写', '204', '/my', '', 1, 204, @operator, NOW()
- -- 锚点行:path 空 + customId 协议值(非数字 customId 是刻意的,前端 modId 会回退主键 id,不影响消费)
- UNION ALL SELECT @center_app_id, NULL, '数据全量', 'fc:scope:all', '', '', 1, 205, @operator, NOW()
) t
WHERE @center_app_id > 0
AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id);
@@ -124,12 +126,13 @@ SELECT g.group_id, a.app_id, @operator, NOW()
WHERE NOT EXISTS (SELECT 1 FROM sys_group_app_rel r
WHERE r.group_id = a.group_id AND r.app_id = a.app_id);
--- 3.3 中心模块授权:管理员组=前五行(除锚点),全员组=只"我的填写",
--- 要看全校数据的组=额外勾"数据全量"锚点行(customId=fc:scope:all)
+-- 3.3 中心模块授权:管理员组=四个管理行(表单设计/数据管理/统计/应用设置),全员组=只"我的填写"。
+-- v1.5 起**没有"要看全校数据就多勾一行"这回事**:数据范围升级改由中心的管理员两表给
+-- (/admin/app 的角色设置 tab 加校级表单管理员,或表单列表/设计器给单表单管理员)。
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_admin, m.id, 0, @operator, NOW()
FROM app_module m
- WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND COALESCE(m.custom_id,'') <> 'fc:scope:all'
+ WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.path <> '/my'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
@@ -138,13 +141,6 @@ SELECT @grp_all, m.id, 0, @operator, NOW()
WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id);
--- 数据全量锚点(V42 的授权动作;只给确实要看全校数据的组)
-INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
-SELECT @grp_admin, m.id, 0, @operator, NOW()
- FROM app_module m
- WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.custom_id = 'fc:scope:all'
- AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
-
-- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_teacher, m.id, 0, @operator, NOW()
@@ -172,21 +168,26 @@ SELECT @grp_teacher, m.id, 0, @operator, NOW()
-- ----------------------------------------------------------------------------
-- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行)
--- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项,锚点行不出现)
+-- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项)
-- ②无授权用户:只见"我的填写"
-- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
--- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(锚点判定生效,P76)
+-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(快应用按钮锚点判定生效,P76)
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报
+-- ⑥中心应用管理端进 /form-center/#/admin/app:应用设置页出两个 tab(角色设置/字典管理),
+-- 角色设置里加的人落 school_admin(数据范围升级,不是菜单行)——T10 V43/V46
-- ----------------------------------------------------------------------------
-SELECT '中心模块行数(期望 6)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id
+SELECT '中心模块行数(期望 5)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id
UNION ALL
SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id
UNION ALL
-SELECT '中心 path 空锚点数(期望 1)', COUNT(*) FROM app_module
+SELECT '中心 path 空行数(期望 0,锚点已退役)', COUNT(*) FROM app_module
WHERE app_id = @center_app_id AND (path IS NULL OR path = '')
UNION ALL
+SELECT '快应用 path 空锚点数(期望 2=导入/导出)', COUNT(*) FROM app_module
+ WHERE app_id = @quick_app_id AND (path IS NULL OR path = '')
+UNION ALL
SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id
UNION ALL
SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p
diff --git a/sql/t10_admin_domain.sql b/sql/t10_admin_domain.sql
new file mode 100644
index 0000000..eabf572
--- /dev/null
+++ b/sql/t10_admin_domain.sql
@@ -0,0 +1,64 @@
+-- ============================================================
+-- 表单中心 T10 · 管理员体系 DDL(MySQL 5.7 兼容复核版)
+-- 范围:《管理员体系方案-20261015》§4 三笔 = school_admin / form_admin / answer 加列+加索引
+-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由 Hibernate
+-- 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 t2/t3 先例)。
+-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写 CHARSET/COLLATE;
+-- ③InnoDB、无物理外键(方案 §7.7 逻辑外键约定:user_id→UserBase、form_uuid→form.uuid 都不建 FK);
+-- ④可空列一律写 DEFAULT NULL,TEXT 类不出现(本脚本无大字段)。
+-- P5/P15 裁决:实体继承 BaseEntity → 实表=方案 §4 草案 + create_by/update_by 两审计列;
+-- 审计列在基类未标 nullable=false,故按建表实态写 DEFAULT NULL(FormCenterAuditListener 恒写 now())。
+-- 重跑口径:MySQL 5.7 无 ADD COLUMN IF NOT EXISTS,本脚本【不是幂等的】——执行前先用
+-- `SHOW COLUMNS FROM answer LIKE 'submitter_sch_id'` 确认列不存在;两表若已建则只跑第三条 ALTER。
+-- 存量:生产零存量(H2 尚未执行、160 在线仍是 T5 旧版),无需回填;本地/perf 库的测试数据不强推
+-- (NULL 行天然不匹配 SCHOOL 谓词=fail-closed,方案 §4 存量条款)。
+-- 部署说明已同步补"加列"一笔(《表单中心160部署Runbook》§0)。
+-- ============================================================
+
+-- ① 校级表单管理员:数据范围扩展载体,取代已退役的 UserBase 侧 customId 权限锚点行(方案 §4①/§7)
+CREATE TABLE `school_admin` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `sch_id` BIGINT NOT NULL COMMENT '租户键(=会话校;超管代查时=显式目标校)',
+ `user_id` INT NOT NULL COMMENT 'UserBase 用户主键(BoaUser.id),逻辑外键不建 FK',
+ `user_name` VARCHAR(64) NOT NULL COMMENT '姓名快照(列表展示免回查,权威在 UserBase)',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_school_admin` (`sch_id`, `user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='校级表单管理员(方案 §5.1:本校表单范围=全部,基础表单范围=本校提交)';
+
+-- ② 单表单管理员:指定表单的数据查看+导出(D3 裁决:不含编辑权)
+CREATE TABLE `form_admin` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT,
+ `form_uuid` VARCHAR(32) NOT NULL COMMENT '授权表单 uuid(跨版本不变,契约 §1)',
+ `sch_id` BIGINT NOT NULL COMMENT '冗余表单租户域(form.sch_id,基础表单=0),落库域复核用',
+ `user_id` INT NOT NULL,
+ `user_name` VARCHAR(64) NOT NULL COMMENT '姓名快照(列表展示免回查,权威在 UserBase)',
+ `create_by` VARCHAR(255) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT NULL,
+ `update_by` VARCHAR(255) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_form_admin` (`form_uuid`, `user_id`),
+ KEY `idx_form_admin_user` (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+ COMMENT='单表单管理员(方案 §5.1:指定表单=全部,基础表单上=本校提交)';
+
+-- ③ 答卷加列:填报人会话学校(D2=B 的落点,SCHOOL 范围的谓词列)
+-- NULL=未记录(存量行),按"不参与校级范围匹配"处理;updatable=false(落值后不随改)
+ALTER TABLE `answer`
+ ADD COLUMN `submitter_sch_id` BIGINT NULL DEFAULT NULL
+ COMMENT '填报人会话学校(提交时从会话 currentRole.schId 落值;NULL=未记录,不参与校级范围匹配)'
+ AFTER `submitter_id`;
+
+ALTER TABLE `answer` ADD KEY `idx_answer_submitter_sch` (`submitter_sch_id`);
+
+-- 自验(执行后逐条核对,V47):
+-- SHOW CREATE TABLE answer\G -- 应含 submitter_sch_id bigint NULL + KEY idx_answer_submitter_sch
+-- EXPLAIN SELECT id FROM answer WHERE sch_id=3260 AND app_code='form-center' AND code='x'
+-- AND is_deleted=0 AND submitter_sch_id=3260; -- SCHOOL 谓词应能走 key 列(V47 探针口径)
+-- SELECT COUNT(*) FROM school_admin; -- 期望 0(新库)
+-- SELECT COUNT(*) FROM form_admin; -- 期望 0(新库)
diff --git a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java
index 45527f9..0fbb738 100644
--- a/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java
+++ b/src/main/java/com/yunhai/formcenter/common/FormModuleKeys.java
@@ -7,14 +7,14 @@ import java.util.List;
import java.util.Set;
/**
- * 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。
+ * 授权模块树的权限键扁平化(方案 §6.1 第②层)。
*
- * 模块行有两种身份,必须分开取:
- *
- * - {@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);
- * - {@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位,
- * 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。
- *
+ * 只收 {@code path} 非空的页面路由行:管理权按它判(手册 §2.2 表)。{@code path} 空的行是
+ * 纯权限位,不进导航——中心应用 v1.5 起不再有这种行,该形态只存在于快应用面的按钮锚点
+ * (导入/导出,由前端 {@code modulePerms} 直读原始树 customId 消费,后端不参与)。
+ *
+ * 数据范围升级位(UserBase 侧的 customId 锚点协议)已在 T10 退役,载体换成管理员两表
+ * ({@code AdminScopeSource},管理员体系方案 §5.1/§7),所以本类不再提供 customId 侧的取键方法。
*
* 纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。
*/
@@ -23,31 +23,17 @@ public final class FormModuleKeys {
private FormModuleKeys() {
}
- /** 授权页面路由集合(path 空的权限锚点不在内) */
+ /** 授权页面路由集合(path 空的权限位不在内) */
public static Set paths(List tree) {
Set result = new LinkedHashSet<>();
- collect(tree, true, result);
+ collect(tree, result);
return result;
}
/**
- * 权限锚点的 customId 集合:只收 path 为空的节点(契约 §8「锚点行形态」注记 / P47)。
- *
- * 数据范围升级位({@code fc:scope:all})按契约只应挂在不进导航的 path 空行上,
- * 扫描必须按这个形态收:带 path 的导航行若误配了该值,不该把看数据的人悄悄升级成"全校全部"
- * ——宽方向的误配同样是不受控的授权膨胀。
+ * 递归展平。取不出 path 的节点自然被跳过,子节点照常下钻(页面行可以挂在分组层下面)。
*/
- public static Set anchorCustomIds(List tree) {
- Set result = new LinkedHashSet<>();
- collect(tree, false, result);
- return result;
- }
-
- /**
- * 递归展平(wantPath=true 取 path;false 取「path 空节点」的 customId)。
- * 取不出值的节点自然被跳过,子节点照常下钻(锚点可以挂在分组层下面)。
- */
- private static void collect(List nodes, boolean wantPath, Set out) {
+ private static void collect(List nodes, Set out) {
if (nodes == null) {
return;
}
@@ -56,11 +42,10 @@ public final class FormModuleKeys {
continue;
}
String path = trimToNull(node.getPath());
- String value = wantPath ? path : (path == null ? trimToNull(node.getCustomId()) : null);
- if (value != null) {
- out.add(value);
+ if (path != null) {
+ out.add(path);
}
- collect(node.getChildren(), wantPath, out);
+ collect(node.getChildren(), out);
}
}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java b/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java
new file mode 100644
index 0000000..9328baa
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/controller/api/AdminDomainController.java
@@ -0,0 +1,102 @@
+package com.yunhai.formcenter.controller.api;
+
+import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.model.AdminModels;
+import com.yunhai.formcenter.service.AdminDomainService;
+import com.yunhai.formcenter.service.FormAdminGuard;
+import com.yunhai.formcenter.service.FormDefService;
+import com.yunhai.platform.utils.model.BoaUser;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 管理员两表的维护端点(T10 方案 §5.3):校级表单管理员三端点摊 {@code /admin/app}(应用设置行),
+ * 单表单管理员三端点摊 {@code /admin/forms}(表单设计行)。
+ *
+ * 这两组端点属于维护面,不是数据面:谁能进角色设置页由 UserBase 模块行决定(守卫红线——
+ * 管理员表本身不作入口凭证),加进去的人只升级数据范围({@code DataScopeService})。
+ * 目标域一律走 {@code FormAdminGuard} 的会话域校验:校级只本校、超管可显式指定(含代查任意校),
+ * 名单里的人再逐个复核归属({@code AdminDomainService})。
+ *
+ * 返回形态:写操作直接回操作后的全量列表(不分页,每校/每表单个位数),
+ * 前端省一次刷新请求,也就不会出现"删了最后一员、列表还留着那行"的中间态。
+ */
+@RestController
+@RequestMapping("/api/form/admin")
+@RequiredArgsConstructor
+public class AdminDomainController {
+
+ private final FormAdminGuard adminGuard;
+ private final FormDefService defService;
+ private final AdminDomainService adminService;
+
+ // ==================== 校级表单管理员 ====================
+
+ /** 全量列表(超管可用 {@code schId} 代查任意校) */
+ @GetMapping("/school-admins")
+ public List listSchoolAdmins(BoaUser user,
+ @RequestParam(required = false) Long schId) {
+ return adminService.listSchoolAdmins(
+ adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
+ }
+
+ @PostMapping("/school-admins")
+ public List addSchoolAdmins(BoaUser user,
+ @RequestBody AdminModels.AdminAddRequest body) {
+ Long domain = adminGuard.requireAdmin(user, body == null ? null : body.getSchId(),
+ FormAdminGuard.MODULE_APP_SETTINGS);
+ return adminService.addSchoolAdmins(domain, body == null ? null : body.getUsers());
+ }
+
+ @DeleteMapping("/school-admins/{id}")
+ public List removeSchoolAdmin(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("id") Long id) {
+ Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS);
+ return adminService.removeSchoolAdmin(domain, id);
+ }
+
+ // ==================== 单表单管理员 ====================
+
+ /** 表单定位走数据面同一条 {@code readDomainForm}:本校 ∪ 平台级可见,他校按"表单不存在"回答 */
+ @GetMapping("/forms/{formUuid}/admins")
+ public List listFormAdmins(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("formUuid") String formUuid) {
+ Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
+ return adminService.listFormAdmins(requireForm(domain, formUuid));
+ }
+
+ @PostMapping("/forms/{formUuid}/admins")
+ public List addFormAdmins(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("formUuid") String formUuid,
+ @RequestBody AdminModels.AdminAddRequest body) {
+ Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
+ Form form = requireForm(domain, formUuid);
+ return adminService.addFormAdmins(form, domain, body == null ? null : body.getUsers());
+ }
+
+ @DeleteMapping("/forms/{formUuid}/admins/{id}")
+ public List removeFormAdmin(BoaUser user,
+ @RequestParam(required = false) Long schId,
+ @PathVariable("formUuid") String formUuid,
+ @PathVariable("id") Long id) {
+ Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
+ Form form = requireForm(domain, formUuid);
+ return adminService.removeFormAdmin(form, id);
+ }
+
+ private Form requireForm(Long domain, String formUuid) {
+ return defService.readDomainForm(domain, formUuid);
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java
index 165a166..d40e4e7 100644
--- a/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAdminController.java
@@ -156,41 +156,41 @@ public class FormAdminController {
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
}
- // ==================== 选项字典(契约 §2.1) ====================
+ // ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ====================
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
@GetMapping("/dict/list")
public List dictList(BoaUser user, @RequestParam(required = false) Long schId) {
- return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
+ return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
}
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
@GetMapping("/dict/selectable")
public List dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
- return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
+ return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
}
@PostMapping("/dict/save")
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
- return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode(), body.getName());
+ return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode(), body.getName());
}
@PostMapping("/dict/delete")
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
- dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode());
+ dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode());
}
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
@PostMapping("/dict/item/save")
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
- return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT),
+ return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS),
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
}
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
@PostMapping("/dict/item/delete")
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
- dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getDictCode(), body.getValue());
+ dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getDictCode(), body.getValue());
}
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java
index d8a7932..ecb0437 100644
--- a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java
@@ -6,6 +6,7 @@ import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.AnswerStatsService;
import com.yunhai.formcenter.service.DataScopeService;
import com.yunhai.formcenter.service.FormAdminGuard;
+import com.yunhai.formcenter.service.FormDefService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
@@ -25,13 +26,14 @@ import java.net.URLEncoder;
import java.util.List;
/**
- * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。
+ * 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。
*
* 权限两层各管一件事,缺一不可(方案 §6.1 三层模型的第②③层):
*
- * - 能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data},
- * 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");
- * - 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId,
+ *
- 能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data}
+ * 或统计 {@code /admin/stats} 任一,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面;
+ * 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);
+ * - 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1;
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。
*
*
@@ -49,6 +51,7 @@ import java.util.List;
public class FormAnswerReadController {
private final FormAdminGuard adminGuard;
+ private final FormDefService defService;
private final AnswerReadService readService;
private final AnswerStatsService statsService;
private final AnswerExportService exportService;
@@ -59,7 +62,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.ListRequest body) {
requireFormUuid(body.getFormUuid());
- Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
+ Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
return readService.list(user, domain, body);
}
@@ -68,7 +71,10 @@ public class FormAnswerReadController {
public ReadModels.DetailView detail(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) {
- Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
+ // 入口时只有答卷 uuid、表单还没定位,故按"数据管理或统计任一"取域;
+ // 域内可见性由 readService.detail 里的同一份范围规格 + visible() 行级把关(与列表同集,V27)
+ Long domain = adminGuard.requireAdminAny(user, schId,
+ FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
return readService.detail(user, domain, answerId);
}
@@ -78,7 +84,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid) {
requireFormUuid(formUuid);
- Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
+ Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(formUuid));
return readService.filterFields(domain, formUuid);
}
@@ -88,8 +94,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.QueryRequest body) {
requireFormUuid(body.getFormUuid());
- Long domain = adminGuard.requireAdminAny(user, schId,
- FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
+ Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
return statsService.stats(user, domain, body);
}
@@ -106,7 +111,7 @@ public class FormAnswerReadController {
@RequestBody ReadModels.QueryRequest body,
HttpServletResponse response) throws IOException {
requireFormUuid(body.getFormUuid());
- Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
+ Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
String fileName = "form-answers.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
diff --git a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java
index 5f8d25e..84555aa 100644
--- a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java
@@ -1,8 +1,12 @@
package com.yunhai.formcenter.controller.api;
+import com.yunhai.formcenter.model.SchoolModels;
+import com.yunhai.formcenter.service.FormAdminGuard;
+import com.yunhai.formcenter.service.SchoolUserTreeService;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -29,11 +33,15 @@ import java.util.Map;
*/
@Slf4j
@RestController
+@RequiredArgsConstructor
public class FormBaseController {
/** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */
private static final String APP_CODE = "form-center";
+ private final FormAdminGuard adminGuard;
+ private final SchoolUserTreeService schoolUserTreeService;
+
/**
* appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。
* 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。
@@ -48,6 +56,21 @@ public class FormBaseController {
return authUserWithDept;
}
+ /**
+ * 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。
+ *
+ * T10 落地的原因:管理员两表的选人入口要"限本校树"(方案 §5.3 末段),而 UserBase 侧
+ * 没有给子应用开放"按 app 过滤的组织树"端点,平台现行做法就是子应用自己代理组树——notice 与
+ * flow-center 各有一份,本端点是同一口径的第三份({@code SchoolUserTreeService})。
+ * 域只从会话推导({@code schId} 不是入参),持应用设置或表单设计任一模块行才能取树。
+ */
+ @GetMapping("/api/base/GetSchoolAll")
+ public List getSchoolAll(BoaUser authUserWithDept) {
+ Long domain = adminGuard.requireAdminAny(authUserWithDept, null,
+ FormAdminGuard.MODULE_APP_SETTINGS, FormAdminGuard.MODULE_FORM_DESIGN);
+ return schoolUserTreeService.teacherTree(domain);
+ }
+
/**
* 当前用户已授权模块树(左侧菜单)。
* 依赖 UserBase 侧数据:本应用的 {@code app_module} 行({@code path} 须逐字对上
@@ -110,8 +133,8 @@ public class FormBaseController {
* modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的
* {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。
* 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。
- * 表单中心的 {@code fc:scope:all}(数据范围权限锚点,契约 §8)是非数字 customId,
- * 且 path 为空、不进导航,故只由后端扫描授权集合消费,不依赖本映射结果。
+ * 中心应用的模块行自 T10(手册 §2.2 v1.5 五行版)起 customId 恒为数字 200~204;非数字时回退主键 id
+ * 只是防御性兜底,不再有"数据范围权限锚点"依赖这条映射(该协议已于 T10 退役,载体换成管理员两表)。
*/
private static Object resolveModId(BoaModule m) {
String customId = m.getCustomId();
diff --git a/src/main/java/com/yunhai/formcenter/entity/Answer.java b/src/main/java/com/yunhai/formcenter/entity/Answer.java
index f420952..462b163 100644
--- a/src/main/java/com/yunhai/formcenter/entity/Answer.java
+++ b/src/main/java/com/yunhai/formcenter/entity/Answer.java
@@ -39,6 +39,8 @@ import javax.persistence.UniqueConstraint;
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"),
@Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"),
@Index(name = "idx_answer_subject", columnList = "subject_id"),
+ // T10 方案 §4③:SCHOOL 范围(本校提交)的谓词列索引
+ @Index(name = "idx_answer_submitter_sch", columnList = "submitter_sch_id"),
// 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id)
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
@@ -82,6 +84,17 @@ public class Answer extends BaseEntity {
@Column(name = "submitter_id", nullable = false, updatable = false)
private Integer submitterId;
+ /**
+ * 填报人会话学校(T10 方案 §4③,D2=B 的落点):范围值 {@code SCHOOL} 的谓词列,
+ * 提交时从会话 {@code currentRole.schId} 落值、之后不改。
+ *
+ *
NULL=未记录(存量行),按"不参与校级范围匹配"处理(fail-closed,方案 §4 存量条款)。
+ * 它与 {@code sch_id} 同源于会话校(校域不变式:见下方建表注记),本列是把"本校提交"
+ * 写成显式谓词,而不是靠租户域隐含它。
+ */
+ @Column(name = "submitter_sch_id", updatable = false)
+ private Long submitterSchId;
+
/** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */
@Column(name = "subject_id")
private Integer subjectId;
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java
new file mode 100644
index 0000000..6a95292
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java
@@ -0,0 +1,44 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的数据查看 + 导出授权(D3 裁决,
+ * 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。
+ *
+ * {@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与
+ * 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为
+ * {@code SCHOOL}(跨校学生数据不因授权而可见,D2 裁决)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "form_admin",
+ uniqueConstraints = @UniqueConstraint(name = "uk_form_admin", columnNames = {"form_uuid", "user_id"}),
+ indexes = @Index(name = "idx_form_admin_user", columnList = "user_id"))
+public class FormAdmin extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 授权的表单(按 uuid,跨版本不变——契约 §1) */
+ @Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
+ private String formUuid;
+
+ /** 冗余表单租户域(form.sch_id,基础表单=0) */
+ @Column(name = "sch_id", nullable = false, updatable = false)
+ private Long schId;
+
+ @Column(name = "user_id", nullable = false, updatable = false)
+ private Integer userId;
+
+ /** 姓名快照,仅供列表展示 */
+ @Column(name = "user_name", nullable = false, length = 64)
+ private String userName;
+}
diff --git a/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java
new file mode 100644
index 0000000..c484bfe
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java
@@ -0,0 +1,42 @@
+package com.yunhai.formcenter.entity;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+
+/**
+ * 校级表单管理员(T10 / 管理员体系方案 §4①):数据范围扩展的载体,取代已退役的 UserBase 侧
+ * customId 权限锚点行(方案 §7)。
+ *
+ * 它只升级数据范围,不是页面入口凭证(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权
+ * 模块行判({@code FormAdminGuard}),这张表只让 {@code DataScopeService} 把范围从"仅本人提交"升到
+ * 本校表单=全部、基础表单=本校提交。
+ *
+ * {@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权
+ * ——解析入口是会话,会话失效即判不到(方案 §8)。
+ */
+@Getter
+@Setter
+@Entity
+@Table(name = "school_admin",
+ uniqueConstraints = @UniqueConstraint(name = "uk_school_admin", columnNames = {"sch_id", "user_id"}))
+public class SchoolAdmin extends BaseEntity {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 租户键(= 会话校;超管代查时=显式指定的目标校) */
+ @Column(name = "sch_id", nullable = false, updatable = false)
+ private Long schId;
+
+ /** UserBase 用户主键({@code BoaUser.id}) */
+ @Column(name = "user_id", nullable = false, updatable = false)
+ private Integer userId;
+
+ /** 姓名快照,仅供列表展示 */
+ @Column(name = "user_name", nullable = false, length = 64)
+ private String userName;
+}
diff --git a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java
index ee698cd..f86ad30 100644
--- a/src/main/java/com/yunhai/formcenter/enums/FormEnums.java
+++ b/src/main/java/com/yunhai/formcenter/enums/FormEnums.java
@@ -101,17 +101,22 @@ public final class FormEnums {
}
/**
- * 数据范围(契约 §8 / 方案 §6.4):闭集四枚举,一期契约即含全部四值——
- * 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。
+ * 数据范围(契约 §8 / 方案 §6.4):一期契约的四值闭集 + T10 的 {@link #SCHOOL}——
+ * "定了就不变"指既不删改既有值、也不改既有语义,扩展走"加枚举=加实现器"这条既定通道
+ * (管理员体系方案 §5.1)。
*
* {@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
* 枚举里有、一期不可选(V28),其实现器只是占位。
+ *
+ * {@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径):
+ * 它不是筛选器选项,而是管理员两表授权后的解析结果(谓词 {@code submitter_sch_id}=会话学校)。
*/
public enum DataScope {
ALL("全部"),
TEACHING_CLASS("任教班级"),
SELF_SUBMITTED("仅本人提交"),
- ABOUT_ME("关于我");
+ ABOUT_ME("关于我"),
+ SCHOOL("本校提交");
private final String label;
diff --git a/src/main/java/com/yunhai/formcenter/model/AdminModels.java b/src/main/java/com/yunhai/formcenter/model/AdminModels.java
new file mode 100644
index 0000000..51c986b
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/AdminModels.java
@@ -0,0 +1,45 @@
+package com.yunhai.formcenter.model;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * 管理员两表的出入参模型(T10 方案 §5.3,中心 {@code /api} 面、信封 code=0 族)。
+ *
+ * 与契约 §1 的"库内 id 不外露"唯一有出入的地方是 {@link AdminView#getId()}:
+ * 方案 §5.3 的删除端点形态就是 {@code DELETE /…/{id}}(管理员行没有 uuid 可用),
+ * 该 id 只在本人的管理面内出现、且删除前还要按 {@code (id, 会话域)} 复核归属,
+ * 不构成答卷数据面那种可枚举资源标识。取舍记在 T10 报告。
+ */
+public final class AdminModels {
+
+ private AdminModels() {
+ }
+
+ /** 选人弹窗提交的一个候选人(userId=UserBase 主键;userName 只做展示快照) */
+ @Getter
+ @Setter
+ public static class AdminUserItem {
+ private Integer userId;
+ private String userName;
+ }
+
+ /** 批量添加请求体:{@code schId} 只是超管的目标域,校级传别的值即拒(同管理面口径) */
+ @Getter
+ @Setter
+ public static class AdminAddRequest {
+ private Long schId;
+ private List users;
+ }
+
+ /** 列表行(每校/每表单个位数,一律不分页,方案 §4) */
+ @Getter
+ @Setter
+ public static class AdminView {
+ private Long id;
+ private Integer userId;
+ private String userName;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java
index 67814eb..0c60dc7 100644
--- a/src/main/java/com/yunhai/formcenter/model/ReadModels.java
+++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java
@@ -178,6 +178,8 @@ public final class ReadModels {
private String code;
private Set scopes;
private Integer userId;
+ /** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */
+ private Long viewerSchId;
private String status;
private List filters;
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
diff --git a/src/main/java/com/yunhai/formcenter/model/SchoolModels.java b/src/main/java/com/yunhai/formcenter/model/SchoolModels.java
new file mode 100644
index 0000000..98ae367
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/model/SchoolModels.java
@@ -0,0 +1,30 @@
+package com.yunhai.formcenter.model;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 本校组织树(选人弹窗数据源)的下发形状(T10 方案 §5.3 末段:同 notice 形态,天然限本校树)。
+ *
+ * 字段名对齐前端移植自 notice 的 {@code UserCheckModal.vue}:它按 {@code isParent} 判组/人
+ * (组=文件夹、叶子=人员),按 {@code children} 递归,{@code id/name} 直接进树。
+ */
+public final class SchoolModels {
+
+ private SchoolModels() {
+ }
+
+ @Getter
+ @Setter
+ public static class Node {
+ /** 组节点=UserBase 组 id;人节点=UserBase 用户主键(即 admin 两表的 user_id) */
+ private Integer id;
+ private String name;
+ /** true=组(可展开),false=人员叶子 */
+ private Boolean isParent;
+ private List children = new ArrayList<>();
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java
new file mode 100644
index 0000000..edaadc8
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java
@@ -0,0 +1,22 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.FormAdmin;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 单表单管理员仓库(T10)。规模=每表单个位数,列表一律不分页(方案 §4)。
+ *
+ * {@code uk_form_admin(form_uuid, user_id)} 是重复添加的幂等兜底;{@code idx_form_admin_user}
+ * 供"我管理的表单"这类按人的反查(v2 入口,本轮不消费,方案 §8 已登记)。
+ */
+public interface FormAdminRepository extends JpaRepository {
+
+ List findByFormUuidOrderByIdAsc(String formUuid);
+
+ Optional findByIdAndFormUuid(Long id, String formUuid);
+
+ boolean existsByFormUuidAndUserId(String formUuid, Integer userId);
+}
diff --git a/src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java b/src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java
new file mode 100644
index 0000000..57a2425
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/repository/SchoolAdminRepository.java
@@ -0,0 +1,24 @@
+package com.yunhai.formcenter.repository;
+
+import com.yunhai.formcenter.entity.SchoolAdmin;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 校级表单管理员仓库(T10)。规模=每校个位数,列表一律不分页(方案 §4)。
+ *
+ * {@code uk_school_admin(sch_id, user_id)} 是重复添加的幂等兜底;行按 id 升序回显,
+ * 与前端"序号/姓名/操作"的列表形态同序。
+ */
+public interface SchoolAdminRepository extends JpaRepository {
+
+ List findBySchIdOrderByIdAsc(Long schId);
+
+ Optional findByIdAndSchId(Long id, Long schId);
+
+ boolean existsBySchIdAndUserId(Long schId, Integer userId);
+
+ long countBySchId(Long schId);
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java
index 27dc31d..978231d 100644
--- a/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java
+++ b/src/main/java/com/yunhai/formcenter/service/AboutMeScopeImplementor.java
@@ -29,7 +29,7 @@ public class AboutMeScopeImplementor implements AnswerScopeImplementor {
}
@Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) {
+ public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("subjectId"), userId);
}
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AdminDomainService.java b/src/main/java/com/yunhai/formcenter/service/AdminDomainService.java
new file mode 100644
index 0000000..f97f2cb
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AdminDomainService.java
@@ -0,0 +1,191 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.entity.FormAdmin;
+import com.yunhai.formcenter.entity.SchoolAdmin;
+import com.yunhai.formcenter.model.AdminModels;
+import com.yunhai.formcenter.repository.FormAdminRepository;
+import com.yunhai.formcenter.repository.SchoolAdminRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 管理员两表的读写(T10 方案 §4/§5.3):{@link AdminScopeSource} 的生产实现 + 管理面 CRUD。
+ *
+ * 三条写侧纪律:
+ *
+ * - 幂等:{@code uk_school_admin(sch_id,user_id)} / {@code uk_form_admin(form_uuid,user_id)}
+ * 是重复添加的兜底——已存在的行直接跳过,不报错也不重复建行(沿登记脚本的幂等哲学);
+ * - 不信任前端:选人弹窗的名单来自 {@code GetSchoolAll()}(天然限本校树),但落库前仍逐个
+ * 走 {@link SubjectDirectory#existsInSchool} 复核归属——传别人的 userId 一律拒(方案 §5.3 末段);
+ * - 防呆:校级管理员删到剩 0 拒绝。没有校级管理员的学校仍能用矩阵默认口径读数据,
+ * 但"删空最后一员"会让此后无人能进角色设置页加人(D6 的自管理闭环断掉),所以宁可按业务规则拒。
+ *
+ *
+ * 行删除一律按 {@code (id, 本次操作域)} 定位({@code findByIdAndSchId}/{@code findByIdAndFormUuid}),
+ * 跨校/跨表单的 id 探测与"不存在"同一口径回答;{@code user_name} 是展示快照,权威始终在 UserBase。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdminDomainService implements AdminScopeSource {
+
+ /** 与 school_admin.user_name / form_admin.user_name 的列宽一致(超长按 PARAM_ERROR 拒,不给 500) */
+ private static final int USER_NAME_MAX = 64;
+
+ private final SchoolAdminRepository schoolAdminRepository;
+ private final FormAdminRepository formAdminRepository;
+ private final SubjectDirectory subjectDirectory;
+
+ // ==================== 只读判定(AdminScopeSource) ====================
+
+ @Override
+ public boolean isSchoolAdmin(Long schId, Integer userId) {
+ return schId != null && userId != null && schoolAdminRepository.existsBySchIdAndUserId(schId, userId);
+ }
+
+ @Override
+ public boolean isFormAdmin(String formUuid, Integer userId) {
+ if (formUuid == null || formUuid.trim().isEmpty() || userId == null) {
+ return false;
+ }
+ return formAdminRepository.existsByFormUuidAndUserId(formUuid.trim(), userId);
+ }
+
+ // ==================== 校级表单管理员(守 /admin/app) ====================
+
+ public List listSchoolAdmins(Long schId) {
+ List views = new ArrayList<>();
+ for (SchoolAdmin row : schoolAdminRepository.findBySchIdOrderByIdAsc(schId)) {
+ views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
+ }
+ return views;
+ }
+
+ /** 批量添加(幂等);返回添加后的全量列表,前端免去第二次请求 */
+ @Transactional
+ public List addSchoolAdmins(Long schId, List users) {
+ for (AdminModels.AdminUserItem item : requireUsers(users)) {
+ requireBelongsToSchool(item, schId);
+ if (schoolAdminRepository.existsBySchIdAndUserId(schId, item.getUserId())) {
+ continue;
+ }
+ SchoolAdmin row = new SchoolAdmin();
+ row.setSchId(schId);
+ row.setUserId(item.getUserId());
+ row.setUserName(item.getUserName());
+ // saveAndFlush:同批里重复的人(前端理论上不会给出,但接口不拒这种形态)也要被 exists 检查看到,
+ // 不能让 uk 撞键把一次正常的幂等添加变成 500
+ schoolAdminRepository.saveAndFlush(row);
+ log.info("[角色设置] 新增校级表单管理员 schId={} userId={} name={}",
+ schId, item.getUserId(), item.getUserName());
+ }
+ return listSchoolAdmins(schId);
+ }
+
+ @Transactional
+ public List removeSchoolAdmin(Long schId, Long id) {
+ SchoolAdmin row = schoolAdminRepository.findByIdAndSchId(id, schId)
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "校级管理员记录不存在:" + id));
+ if (schoolAdminRepository.countBySchId(schId) <= 1) {
+ throw new BizException(ErrorCode.CUSTOM, "本校至少保留一名校级表单管理员,最后一名不可删除");
+ }
+ schoolAdminRepository.delete(row);
+ log.info("[角色设置] 删除校级表单管理员 schId={} userId={} rowId={}", schId, row.getUserId(), row.getId());
+ return listSchoolAdmins(schId);
+ }
+
+ // ==================== 单表单管理员(守 /admin/forms) ====================
+
+ public List listFormAdmins(Form form) {
+ List views = new ArrayList<>();
+ for (FormAdmin row : formAdminRepository.findByFormUuidOrderByIdAsc(form.getUuid())) {
+ views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
+ }
+ return views;
+ }
+
+ /**
+ * 批量添加(幂等)。
+ *
+ * @param domain 本次操作域(守卫返回的租户域)——基础表单的 {@code form.schId=0} 不是可复核的学校,
+ * 归属复核按 域 走:校级操作人=会话校(因此只能把自己学校的人加进基础表单的管理员),
+ * 超管平台态(域=0)在此被 {@link SubjectDirectory} 判为"不属于本校"而拒,
+ * 需要给基础表单授权时显式带 {@code schId=某校}。取舍记在 T10 报告。
+ */
+ @Transactional
+ public List addFormAdmins(Form form, Long domain,
+ List users) {
+ for (AdminModels.AdminUserItem item : requireUsers(users)) {
+ requireBelongsToSchool(item, domain);
+ if (formAdminRepository.existsByFormUuidAndUserId(form.getUuid(), item.getUserId())) {
+ continue;
+ }
+ FormAdmin row = new FormAdmin();
+ row.setFormUuid(form.getUuid());
+ row.setSchId(form.getSchId());
+ row.setUserId(item.getUserId());
+ row.setUserName(item.getUserName());
+ formAdminRepository.saveAndFlush(row);
+ log.info("[表单管理员] 新增 form={} userId={} name={}",
+ form.getCode(), item.getUserId(), item.getUserName());
+ }
+ return listFormAdmins(form);
+ }
+
+ @Transactional
+ public List removeFormAdmin(Form form, Long id) {
+ FormAdmin row = formAdminRepository.findByIdAndFormUuid(id, form.getUuid())
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单管理员记录不存在:" + id));
+ formAdminRepository.delete(row);
+ log.info("[表单管理员] 删除 form={} userId={}", form.getCode(), row.getUserId());
+ return listFormAdmins(form);
+ }
+
+ // ==================== 内部 ====================
+
+ /** 请求形态校验:非空名单 + 每人有 userId、有姓名快照(列宽内) */
+ private List requireUsers(List users) {
+ if (users == null || users.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一人(users 为空)");
+ }
+ for (AdminModels.AdminUserItem item : users) {
+ if (item == null || item.getUserId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少 userId");
+ }
+ String name = item.getUserName() == null ? null : item.getUserName().trim();
+ if (name == null || name.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少姓名:userId=" + item.getUserId());
+ }
+ if (name.length() > USER_NAME_MAX) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "姓名快照过长(≤" + USER_NAME_MAX + "):userId=" + item.getUserId());
+ }
+ item.setUserName(name);
+ }
+ return users;
+ }
+
+ /** 落库前的归属复核(方案 §5.3:不信任前端,跨校传参一律拒) */
+ private void requireBelongsToSchool(AdminModels.AdminUserItem item, Long schId) {
+ if (!subjectDirectory.existsInSchool(item.getUserId(), schId)) {
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "该用户不属于本次管理域,不能加为管理员:userId=" + item.getUserId() + " schId=" + schId);
+ }
+ }
+
+ private static AdminModels.AdminView toView(Long id, Integer userId, String userName) {
+ AdminModels.AdminView view = new AdminModels.AdminView();
+ view.setId(id);
+ view.setUserId(userId);
+ view.setUserName(userName);
+ return view;
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java b/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java
new file mode 100644
index 0000000..002bbc3
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/AdminScopeSource.java
@@ -0,0 +1,21 @@
+package com.yunhai.formcenter.service;
+
+/**
+ * 管理员两表的只读判定端口(T10 方案 §5.1):范围解析与守卫都从这里问"这个人在不在
+ * {@code school_admin} / {@code form_admin} 里",生产实现是 {@code AdminDomainService}(直查本库两表)。
+ *
+ * 为什么做成端口:{@code DataScopeService} 与 {@code FormAdminGuard} 都只消费这两个布尔判定,
+ * 加宽一侧(比如将来按人反查"我管理的表单",方案 §8 的 v2 入口)不该把仓库与写侧逻辑渗进解析器;
+ * 同 {@link ModuleTreeSource}/{@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。
+ *
+ * 口径:判定只认会话身份({@code (schId, userId)} / {@code (formUuid, userId)}),
+ * 查不到即 false(fail-closed)——残留行(教师离校)因会话失效而永远走不到这里,方案 §8。
+ */
+public interface AdminScopeSource {
+
+ /** 该用户是否 {@code schId} 的校级表单管理员 */
+ boolean isSchoolAdmin(Long schId, Integer userId);
+
+ /** 该用户是否这张表单(按 uuid)的单表单管理员 */
+ boolean isFormAdmin(String formUuid, Integer userId);
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java
index ece1ca3..e9c6ecd 100644
--- a/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java
+++ b/src/main/java/com/yunhai/formcenter/service/AllScopeImplementor.java
@@ -8,7 +8,7 @@ import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
-/** 范围实现器:全部——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */
+/** 范围实现器:全部——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */
@Component
public class AllScopeImplementor implements AnswerScopeImplementor {
@@ -23,7 +23,7 @@ public class AllScopeImplementor implements AnswerScopeImplementor {
}
@Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) {
+ public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
return cb.conjunction();
}
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
index a255f76..60accd4 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
@@ -72,7 +72,7 @@ public class AnswerReadService {
/**
* 会话身份 + 表单 + 请求 → 读路径查询规格。
*
- * 范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来
+ *
范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + 管理员两表解析,解析不出来
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按当前发布版本的
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。
@@ -84,6 +84,8 @@ public class AnswerReadService {
query.setCode(form.getCode());
query.setScopes(scopeService.resolve(user, form));
query.setUserId(user == null ? null : user.getId());
+ // T10 方案 §5.1:SCHOOL 范围的谓词基准=会话学校(不是查询域,两者同源于会话校但语义不同)
+ query.setViewerSchId(user == null ? null : sessionSchId(user));
query.setStatus(req == null ? null : trimToNull(req.getStatus()));
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
return query;
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java
index 45c4ba9..4cc23bd 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeImplementor.java
@@ -31,8 +31,10 @@ public interface AnswerScopeImplementor {
/**
* 产出该范围对单条答卷的行级谓词。
*
- * @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
+ * @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
+ * @param viewerSchId 会话学校({@code submitter_sch_id} 的比对基准,T10 方案 §5.1;
+ * 只有 {@code SCHOOL} 消费它,其余范围按各自基准忽略即可)
* @return 恒真({@code cb.conjunction()})=该范围不额外收窄行
*/
- Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId);
+ Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId);
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java
index b4b5a2a..7ee07e3 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java
@@ -180,6 +180,7 @@ public class AnswerScopeQueryBuilder {
copy.setCode(q.getCode());
copy.setScopes(q.getScopes());
copy.setUserId(q.getUserId());
+ copy.setViewerSchId(q.getViewerSchId());
copy.setStatus(q.getStatus());
copy.setFilters(q.getFilters());
copy.setCursorId(q.getCursorId());
@@ -234,7 +235,8 @@ public class AnswerScopeQueryBuilder {
}
List ors = new ArrayList<>();
for (DataScope scope : scopes) {
- ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q.getUserId()));
+ ors.add(scopeService.implementorOf(scope)
+ .rowPredicate(cb, root, q.getUserId(), q.getViewerSchId()));
}
return cb.or(ors.toArray(new Predicate[0]));
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java
index f5af0f9..92cbbb9 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java
@@ -135,6 +135,9 @@ public class AnswerService {
answer.setCode(form.getCode());
answer.setVersion(published.versionRow().getVersion());
answer.setSubmitterId(submitterId);
+ // T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证
+ // (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)
+ answer.setSubmitterSchId(schId);
answer.setSubjectId(subjectId);
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
answer.setValuesJson(valuesJson);
diff --git a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
index 6332784..1c972f2 100644
--- a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
+++ b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
@@ -1,6 +1,5 @@
package com.yunhai.formcenter.service;
-import com.yunhai.formcenter.common.FormModuleKeys;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
@@ -19,12 +18,17 @@ import java.util.List;
import java.util.Set;
/**
- * 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围,
- * 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
+ * 数据范围解析器(契约 §8 / 方案 §6.4 / 管理员体系方案 §5.1):把"会话用户 + 这张表单"解析成一组
+ * 可见范围,交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
*
- * 解析规则(优先级):{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵},
+ *
解析规则(优先级):{@code 范围 = 管理员两表授权 ∪ 身份默认矩阵},
* 命中"全部"即收敛为全部;全不命中回落仅本人提交(宁紧勿松)。
*
+ * 管理员两表来源(T10 起,取代已退役的 {@code customId} 权限锚点行):
+ * {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级——
+ * 本校表单=全部(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})=
+ * 本校提交(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。
+ *
* "多来源取最宽"在这里的实现是并集:一个用户可能同时有多个身份({@code roleIds} 里既有
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。
@@ -39,18 +43,16 @@ import java.util.Set;
* 4/6/5 仅本人提交 仅本人提交
*
*
- * 与 {@link FormAdminGuard} 是两条正交的口径:守卫答"能不能进这个管理面"(授权模块行),
- * 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求),
- * 但一个是 path、一个是 customId,不要混用。
+ * 与 {@link FormAdminGuard} 是两条正交的口径:守卫答"能不能进这个管理面"(UserBase 授权模块行,
+ * 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。两条线的事实源不同——模块行来自 UserBase
+ * ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面
+ * 入口凭证(方案 §3 三层模型不变)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataScopeService {
- /** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */
- public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all";
-
/** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */
private static final int SUPER_ADMIN_TYPE = 1;
@@ -59,13 +61,14 @@ public class DataScopeService {
private static final int ROLE_STUDENT = 3;
private static final int ROLE_ADMIN = 7;
- private final ModuleTreeSource moduleTreeSource;
private final List implementors;
+ private final AdminScopeSource adminScopeSource;
/**
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
*
- * @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理
+ * @param form 目标表单({@code schId} 决定两表升级成哪一档、{@code audience} 决定用矩阵哪一列);
+ * null 按面向学生列解析,且不产生两表升级(无表单域可判)
*/
public Set resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) {
@@ -75,9 +78,10 @@ public class DataScopeService {
return EnumSet.of(DataScope.ALL);
}
Set scopes = new LinkedHashSet<>();
- // 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认)
- if (hasScopeAllAnchor(user)) {
- scopes.add(DataScope.ALL);
+ // 1) 管理员两表授权(契约 §8 v1.5 优先级:管理员来源授权 > 身份默认)
+ DataScope granted = grantedByAdminTables(user, form);
+ if (granted != null) {
+ scopes.add(granted);
}
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
int audience = form == null || form.getAudience() == null
@@ -96,8 +100,8 @@ public class DataScopeService {
}
/**
- * 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里,
- * 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。
+ * 一期可选的范围值(V28 的断言载体):{@code 任教班级} 的实现器 {@code selectable()=false},
+ * T10 新增的 {@code SCHOOL} 同样 false——它不是筛选器选项,是授权结果(方案 §5.1 明示)。
*/
public Set selectableScopes() {
Set result = new LinkedHashSet<>();
@@ -119,9 +123,34 @@ public class DataScopeService {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
}
- /** 是否命中 fc:scope:all 权限锚点(只认 path 空节点,P47;见 FormModuleKeys#anchorCustomIds) */
- private boolean hasScopeAllAnchor(BoaUser user) {
- return FormModuleKeys.anchorCustomIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
+ /**
+ * 管理员两表在这张表单上应得的范围值(不给=null,交给矩阵)。
+ *
+ * 两张表的映射口径逐字相同(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定;
+ * 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。
+ */
+ private DataScope grantedByAdminTables(BoaUser user, Form form) {
+ if (form == null || form.getSchId() == null) {
+ return null;
+ }
+ Long ownSch = sessionSchId(user);
+ if (ownSch == null) {
+ // 会话给不出学校:既没有"本校表单"可认,SCHOOL 也没有比对基准——不给任何升级
+ return null;
+ }
+ boolean schoolAdmin = adminScopeSource.isSchoolAdmin(ownSch, user.getId());
+ if (!schoolAdmin && !adminScopeSource.isFormAdmin(form.getUuid(), user.getId())) {
+ return null;
+ }
+ long formSch = form.getSchId();
+ if (formSch == ownSch) {
+ return DataScope.ALL;
+ }
+ if (formSch == Form.PLATFORM_SCH_ID) {
+ return DataScope.SCHOOL;
+ }
+ // 他校表单:两表都不覆盖(校级管理员的授权域=会话校,跨校不成立)
+ return null;
}
/**
@@ -163,4 +192,10 @@ public class DataScopeService {
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
return DataScope.SELF_SUBMITTED;
}
+
+ /** 会话校(Integer→Long 换算;无当前身份或无学校归属=null,两表升级不参与) */
+ private static Long sessionSchId(BoaUser user) {
+ Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
+ return schId == null ? null : Long.valueOf(schId);
+ }
}
diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java
index 0859452..dbb009f 100644
--- a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java
+++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java
@@ -18,15 +18,18 @@ import java.util.Set;
* 因此不存在"菜单看得见、接口调不动"的两套口径):
*
* - 定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
- * 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。
+ * 应用设置={@code /admin/app}(T10 方案 §3:字典管理行撤销、迁入应用设置页内 tab,
+ * 字典 API 摊这一行把关;手册 §2.2 表 v1.5 起是五行版)。
* - 超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、
* 且是唯一能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定,
* 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。
*
*
- * 与管理权正交的另一条线:数据面能看哪些行由契约 §8 的角色矩阵 + customId 决定
+ *
与管理权正交的另一条线:数据面能看哪些行由契约 §8 的角色矩阵 + 管理员两表决定
* ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
- * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。
+ * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。
+ * 管理员两表不充当页面入口凭证(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里
+ * 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。
*/
@Service
@RequiredArgsConstructor
@@ -41,8 +44,11 @@ public class FormAdminGuard {
public static final String MODULE_DATA_MANAGE = "/admin/data";
/** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */
public static final String MODULE_STATS = "/admin/stats";
- /** 字典管理模块行 path */
- public static final String MODULE_DICT = "/admin/dicts";
+ /**
+ * 应用设置模块行 path(T10 方案 §3,取代原字典管理行 {@code /admin/dicts}):
+ * 页内两个 tab(角色设置/字典管理)共用这一行,字典 API 也摊它把关。
+ */
+ public static final String MODULE_APP_SETTINGS = "/admin/app";
private final ModuleTreeSource moduleTreeSource;
@@ -136,6 +142,46 @@ public class FormAdminGuard {
"无该模块权限(需任一):" + String.join(" 或 ", modulePaths));
}
+ /**
+ * 数据面读出路径的入口判定(T10 方案 §5.2):列表 / 筛选候选 / 统计 / 导出四条按表单寻址的
+ * 读接口用它,返回本次查询的租户域。与 {@link #requireAdmin} 的差别只有两处:
+ *
+ * - 数据管理或统计任一模块行即可(手册 §2.2 的"统计可并入数据管理,二选一"推广到整个
+ * 数据面——只授统计行的人不该在列表页拿到 403 而菜单里又确实有数据面);
+ * - 按表单归属域判,于是基础表单({@code sch_id=0})在本校域下可读(T4 起就是这个口径,
+ * 本方法不收窄它),而他校表单与"表单不存在"同口径回答(uuid 不可枚举,契约 §1)。
+ *
+ *
+ * 进来之后能看哪些行仍然只由 {@link DataScopeService} 决定:管理员两表在这里不作入口凭证
+ * (T10 任务书 §0-4 守卫红线),它们只把范围从"仅本人提交"升到"全部/本校提交"。
+ *
+ * 详情面(按 answerId 寻址)入口时还没有表单对象,沿用 {@link #requireAdminAny} 取域 +
+ * 构造器 {@code visible()} 的行级把关——同一份范围规格,可见集与列表必然同集(V27 的断言形态)。
+ */
+ public Long requireDataView(BoaUser user, Long targetSchId, Form form) {
+ if (user == null || user.getId() == null) {
+ throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
+ }
+ if (isSuperAdmin(user)) {
+ // 超管的域语义原样交给 requireAdmin:显式目标域(含 0=平台级)优先,不在这里换默认值
+ return requireAdmin(user, targetSchId, MODULE_DATA_MANAGE);
+ }
+ if (form == null || form.getSchId() == null) {
+ // 取不到表单与"不是你域内的表单"同一口径:存在性本身就是探测面(契约 §1 uuid 不可枚举)
+ throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在");
+ }
+ Long current = currentSchId(user);
+ if (current == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定查询域");
+ }
+ long formSch = form.getSchId();
+ if (formSch != current && formSch != Form.PLATFORM_SCH_ID) {
+ // 他校表单:按"不存在"回答(同 FormDefService#readDomainForm 的拒绝口径)
+ throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid());
+ }
+ return requireAdminAny(user, targetSchId, MODULE_STATS, MODULE_DATA_MANAGE);
+ }
+
/** 会话用户被授权的模块行 path 集合(数据范围解析与调试取证用) */
public Set authorizedPaths(BoaUser user) {
return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user));
diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java
index 7d8744a..909afce 100644
--- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java
+++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java
@@ -229,6 +229,18 @@ public class FormDefService {
return form;
}
+ /**
+ * 数据面读接口入口把关用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定,
+ * 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}——
+ * 那里才知道"他校表单与不存在的表单要用同一口径回答",本方法刻意不给拒绝话术。
+ */
+ public Form findFormForGuard(String formUuid) {
+ if (formUuid == null || formUuid.trim().isEmpty()) {
+ return null;
+ }
+ return formRepository.findByUuid(formUuid.trim()).orElse(null);
+ }
+
// ==================== 写:草稿/发布/生命周期 ====================
/** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
diff --git a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java
index cda853b..a794488 100644
--- a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java
+++ b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java
@@ -8,12 +8,12 @@ import java.util.List;
/**
* 会话用户在本应用下的已授权模块树来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
*
- * T4 起两类判断都从这棵树出,共用同一个接口(执行手册 §5 P21/P30 裁决落地口径):
- *
- * - 管理权:定义管理/数据管理/统计/字典管理=被授权对应模块行(path 命中,手册 §2.2 表);
- * - 数据范围:{@code fc:scope:all} 授权锚点=这棵树里 path 空、customId 为该值的纯权限位
- * (契约 §8 扩展协议)。
- *
+ * 本树回答管理权一类问题(执行手册 §5 P21/P30 裁决落地口径):定义管理/数据管理/统计/
+ * 应用设置=被授权对应模块行(path 命中,手册 §2.2 表 v1.5 五行版)。
+ *
+ * T10 起数据范围不再从这棵树出:{@code customId} 权限锚点协议退役(管理员体系方案 §7),
+ * 范围升级改由本库的管理员两表提供({@link AdminScopeSource})。path 空的锚点形态本身仍存在于
+ * 快应用面(导入/导出按钮,前端 {@code modulePerms} 消费,T9/P76 产物),只是中心后端不再扫描它。
*
* 做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。
* 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责);
@@ -24,6 +24,6 @@ import java.util.List;
*/
public interface ModuleTreeSource {
- /** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */
+ /** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */
List authorizedModules(BoaUser user);
}
diff --git a/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java
new file mode 100644
index 0000000..65fbb79
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/SchoolScopeImplementor.java
@@ -0,0 +1,45 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.entity.Answer;
+import com.yunhai.formcenter.enums.FormEnums.DataScope;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import org.springframework.stereotype.Component;
+
+import javax.persistence.criteria.CriteriaBuilder;
+import javax.persistence.criteria.Predicate;
+import javax.persistence.criteria.Root;
+
+/**
+ * 范围实现器:本校提交({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。
+ *
+ * 它是管理员两表在基础表单(sch_id=0)上的落地形态:校级/单表单管理员都不因授权而看见
+ * 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"——
+ * 那等于把本校范围读成全平台。
+ *
+ * {@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放",
+ * 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明
+ * 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。
+ */
+@Component
+public class SchoolScopeImplementor implements AnswerScopeImplementor {
+
+ @Override
+ public DataScope scope() {
+ return DataScope.SCHOOL;
+ }
+
+ @Override
+ public boolean selectable() {
+ return false;
+ }
+
+ @Override
+ public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
+ if (viewerSchId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "数据范围 本校提交 需要会话学校身份");
+ }
+ // NULL 存量行天然不匹配等值谓词(fail-closed,方案 §4 存量条款),不需要额外的 is null 分支
+ return cb.equal(root.get("submitterSchId"), viewerSchId);
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java b/src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java
new file mode 100644
index 0000000..27ff299
--- /dev/null
+++ b/src/main/java/com/yunhai/formcenter/service/SchoolUserTreeService.java
@@ -0,0 +1,161 @@
+package com.yunhai.formcenter.service;
+
+import com.yunhai.formcenter.model.SchoolModels;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.openapi.base.OpenApiGroupClient;
+import com.yunhai.platform.openapi.clazz.YschClassOpenApi;
+import com.yunhai.platform.openapi.clazz.model.SchTea;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import com.yunhai.platform.utils.model.BoaGroup;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 本校组织树(选人弹窗的数据源,T10 方案 §5.3 末段"同 notice 形态,天然限本校树")。
+ *
+ * 口径抄的是平台现行实现:组树只取骨架并整棵剔除学生组({@code type=8}),成员来自 userclass
+ * 的教师列表({@code YschClassOpenApi.getSchTeaList})按所属组挂载——notice 的 {@code GetSchoolAll} 与
+ * flow-center 的 {@code /admin/user/teacherTree} 都是这一条。不走 {@code getGroupUsers}:那条会把
+ * 管理员等非教师账号混进树(flow-center 注释里写明了这是"选人数据不对"的根因)。
+ *
+ * 教师列表拉不到时只回组骨架并打 warn(同上先例):空组会被剪掉,前端看到的是"只有学校根节点"
+ * 的空树,比抛 500 更能在实拍里被发现;但组树本身取不到是硬失败(域都给不出,不能假装没人)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SchoolUserTreeService {
+
+ /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP)——管理员候选只可能是教职工 */
+ private static final int GROUP_TYPE_STUDENT = 8;
+
+ private final OpenApiGroupClient groupClient;
+
+ /** 教师版组织树(根=学校组,叶子=教师);{@code schId} 只从会话域来,不接受外部传入 */
+ public List teacherTree(Long schId) {
+ if (schId == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能取本校组织树");
+ }
+ BoaGroup subtree;
+ try {
+ subtree = groupClient.getGroupById(schId.intValue(), true);
+ } catch (Exception e) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
+ }
+ if (subtree == null || subtree.getId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
+ }
+
+ Map groups = new LinkedHashMap<>();
+ List flat = new ArrayList<>();
+ flattenGroups(subtree, flat);
+ for (BoaGroup group : flat) {
+ SchoolModels.Node node = new SchoolModels.Node();
+ node.setId(group.getId());
+ node.setName(group.getName() == null ? "" : group.getName());
+ node.setIsParent(true);
+ groups.put(group.getId(), node);
+ }
+ attachTeachers(groups, schId);
+ for (BoaGroup group : flat) {
+ for (BoaGroup child : group.getChildren() == null
+ ? Collections.emptyList() : group.getChildren()) {
+ SchoolModels.Node childNode = child == null ? null : groups.get(child.getId());
+ SchoolModels.Node parent = groups.get(group.getId());
+ if (childNode != null && parent != null) {
+ parent.getChildren().add(childNode);
+ }
+ }
+ }
+ SchoolModels.Node root = groups.get(schId.intValue());
+ if (root == null) {
+ return new ArrayList<>();
+ }
+ pruneEmptyGroups(root);
+ return Collections.singletonList(root);
+ }
+
+ /** 教师按所属组挂叶子;归属组全被剔除(如只在学生组)时兜底挂学校根 */
+ private void attachTeachers(Map groups, Long schId) {
+ List teachers;
+ try {
+ teachers = YschClassOpenApi.getSchTeaList(schId.intValue());
+ } catch (Exception e) {
+ log.warn("[选人树] 教师列表拉取失败,本轮只回组骨架 schId={}:{}", schId, e.getMessage());
+ return;
+ }
+ if (teachers == null) {
+ return;
+ }
+ for (SchTea tea : teachers) {
+ if (tea == null || tea.getUserTeaSerId() == null) {
+ continue;
+ }
+ SchoolModels.Node leaf = new SchoolModels.Node();
+ leaf.setId(tea.getUserTeaSerId());
+ leaf.setName(tea.getTeaName() == null ? "" : tea.getTeaName());
+ leaf.setIsParent(false);
+ List groupIds = tea.getGroupIds();
+ if ((groupIds == null || groupIds.isEmpty()) && tea.getGroupId() != null) {
+ groupIds = Collections.singletonList(tea.getGroupId());
+ }
+ boolean attached = false;
+ if (groupIds != null) {
+ for (Integer groupId : groupIds) {
+ SchoolModels.Node group = groups.get(groupId);
+ if (group != null) {
+ group.getChildren().add(leaf);
+ attached = true;
+ }
+ }
+ }
+ if (!attached) {
+ SchoolModels.Node root = groups.get(schId.intValue());
+ if (root != null) {
+ root.getChildren().add(leaf);
+ }
+ }
+ }
+ }
+
+ /** 组子树扁平化;学生组(type=8)整棵剔除(含其后代) */
+ private void flattenGroups(BoaGroup group, List out) {
+ if (group == null || group.getId() == null) {
+ return;
+ }
+ if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
+ return;
+ }
+ out.add(group);
+ if (group.getChildren() != null) {
+ for (BoaGroup child : group.getChildren()) {
+ flattenGroups(child, out);
+ }
+ }
+ }
+
+ /**
+ * 剪掉没有任何教师的空组(否则选人树里全是点不动的文件夹)。返回该子树里是否还有人员叶子;
+ * 学校根节点由调用方无条件保留,所以"本校一个教师都没拉到"时前端看到的是一棵只有根的空树。
+ */
+ private boolean pruneEmptyGroups(SchoolModels.Node node) {
+ if (!Boolean.TRUE.equals(node.getIsParent())) {
+ return true;
+ }
+ List kept = new ArrayList<>();
+ for (SchoolModels.Node child : node.getChildren()) {
+ if (pruneEmptyGroups(child)) {
+ kept.add(child);
+ }
+ }
+ node.setChildren(kept);
+ return !kept.isEmpty();
+ }
+}
diff --git a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java
index 0936f24..b2cff62 100644
--- a/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java
+++ b/src/main/java/com/yunhai/formcenter/service/SelfSubmittedScopeImplementor.java
@@ -29,7 +29,7 @@ public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
}
@Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) {
+ public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
return cb.equal(root.get("submitterId"), userId);
}
}
diff --git a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java
index 5bd2a95..e66b8df 100644
--- a/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java
+++ b/src/main/java/com/yunhai/formcenter/service/TeachingClassScopeImplementor.java
@@ -35,7 +35,7 @@ public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
}
@Override
- public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId) {
+ public Predicate rowPredicate(CriteriaBuilder cb, Root root, Integer userId, Long viewerSchId) {
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
}
diff --git a/src/main/java/com/yunhai/formcenter/service/package-info.java b/src/main/java/com/yunhai/formcenter/service/package-info.java
index c808b26..cbffce2 100644
--- a/src/main/java/com/yunhai/formcenter/service/package-info.java
+++ b/src/main/java/com/yunhai/formcenter/service/package-info.java
@@ -1,6 +1,6 @@
/**
* 表单中心服务域(T2 起填充):定义域(发布/升版/复制/字典/schema 校验器)、
- * 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + fc:scope:all)、
+ * 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + 管理员两表)、
* 检索统计与导出、上传模块(FILE_PATH/FILE_WEB_PATH,值只存 {id,fileName,key})。
*/
package com.yunhai.formcenter.service;
diff --git a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java
index cac82f2..9fd544d 100644
--- a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java
+++ b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java
@@ -1,5 +1,6 @@
package com.yunhai.formcenter.controller.api;
+import com.yunhai.formcenter.service.FormAdminGuard;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.Test;
@@ -46,7 +47,7 @@ class FormBaseControllerTest {
@Test
void toFrontendTree_shouldAlwaysEmitChildrenAsArray() {
- BoaModule configuredLeaf = module(601, "102", "/admin/dicts", 1);
+ BoaModule configuredLeaf = module(601, "102", "/admin/app", 1);
configuredLeaf.setChildren(new ArrayList<>());
BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null
@@ -60,11 +61,11 @@ class FormBaseControllerTest {
@Test
void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() {
- // 数据范围权限锚点 fc:scope:all(契约 §8)就是非数字 customId:它 path 为空、不进导航,
- // 只由后端扫描授权集合消费,这里只保证映射不炸、modId 退回主键。
- BoaModule scopeAnchor = module(701, "fc:scope:all", null, 1);
+ // 手册 §2.2 v1.5 起中心五行的 customId 恒为数字(200~204),非数字只是上游配错时的兜底:
+ // 映射不炸、modId 退回主键、path 空行给空串(前端导航按 path 空跳过,快应用按钮锚点同形态)
+ BoaModule misconfigured = module(701, "not-a-number", null, 1);
- List