详情面(按 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