emptyList() : body.getAnswerIds());
return adminService.batchChangeStatus(user, domain, body);
}
}
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 ecb0437..0f2e1d6 100644
--- a/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormAnswerReadController.java
@@ -28,16 +28,16 @@ import java.util.List;
/**
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。
*
- * 权限两层各管一件事,缺一不可(方案 §6.1 三层模型的第②③层):
+ * 权限两层各管一件事,缺一不可(契约 v2 C4② 换了第①层的判据来源):
*
- * - 能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data}
- * 或统计 {@code /admin/stats} 任一,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面;
- * 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);
- * - 进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1;
+ *
- 能不能进这个接口=该表单 ALL 档({@link FormAdminGuard#requireData};
+ * 统计/列表/筛选/导出都吃同一道门。详情按 answerId 寻址、入口时手上还没有表单对象,
+ * 故先由 uuid 反查所属表单再判({@code requireDataByAnswer});
+ * - 进来后能看哪些行=数据范围({@link DataScopeService} 两档解析 + 创建人/两表/角色,
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。
*
*
- * 所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校——
+ *
所以"拿到 ALL 档入口的人"看到的仍是他范围内的数据——ALL 档在平台级表单上会收窄成本校提交行——
* 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。
*
* 身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径);
@@ -62,7 +62,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.ListRequest body) {
requireFormUuid(body.getFormUuid());
- Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
+ Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid()));
return readService.list(user, domain, body);
}
@@ -73,8 +73,7 @@ public class FormAnswerReadController {
@RequestParam("answerId") String answerId) {
// 入口时只有答卷 uuid、表单还没定位,故按"数据管理或统计任一"取域;
// 域内可见性由 readService.detail 里的同一份范围规格 + visible() 行级把关(与列表同集,V27)
- Long domain = adminGuard.requireAdminAny(user, schId,
- FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
+ Long domain = adminGuard.requireDataByAnswer(user, schId, answerId);
return readService.detail(user, domain, answerId);
}
@@ -84,7 +83,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid) {
requireFormUuid(formUuid);
- Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(formUuid));
+ Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(formUuid));
return readService.filterFields(domain, formUuid);
}
@@ -94,7 +93,7 @@ public class FormAnswerReadController {
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.QueryRequest body) {
requireFormUuid(body.getFormUuid());
- Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
+ Long domain = adminGuard.requireData(user, schId, defService.findFormForGuard(body.getFormUuid()));
return statsService.stats(user, domain, body);
}
@@ -111,7 +110,7 @@ public class FormAnswerReadController {
@RequestBody ReadModels.QueryRequest body,
HttpServletResponse response) throws IOException {
requireFormUuid(body.getFormUuid());
- Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
+ Long domain = adminGuard.requireData(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 84555aa..3d54aef 100644
--- a/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java
+++ b/src/main/java/com/yunhai/formcenter/controller/api/FormBaseController.java
@@ -3,54 +3,35 @@ 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;
import org.springframework.web.bind.annotation.RestController;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
/**
- * 前端壳基础端点(对齐 UserBase BaseController 的 /api/base/* 形态):GetUserInfo / GetUserMod。
- *
GetUserMod 与其他子应用(Notice / svc-hall / 事项库 Cms / flow-center)统一走平台下发:经 SDK
- * {@code YschBaseOpenApi.getUserModule} 取当前用户在本应用({@code yh.app-auth.app-key})下的
- * 已授权模块树,层级由平台契约 {@code BoaModule.children} 承载,本侧仅映射为前端 layout
- * 期望的字段形状({@code page/modId/children})。
- *
target appCode(快应用上下文,方案 §6.2 / 任务书 T8):{@code /app/{appCode}/} 需要的是
- * 那个快应用的模块树,而不是表单中心自己的。它依赖 UserBase {@code /oapi/user/modules} 的
- * 可选 appCode 参数(跨项目触点 H4),SDK 侧目前只有
- * {@code getUserModule(int userId, Integer schId)} 一个形态、无 appCode 入参。
- * H4 上线前本端点对该请求返回空树并打 warn——不静默返回本应用菜单,
- * 那会把「请求 A 应用的树」伪装成「A 应用就是这些菜单」。
+ * 前端壳基础端点(对齐 UserBase BaseController 的 {@code /api/base/*} 形态)。
+ *
+ *
契约 v2 §2.4 之后本类只剩两个端点:
+ *
+ * - {@code GetUserInfo}——会话用户本体,App.vue 引导的唯一请求(左导航改由三支路能力位自解析:
+ * {@code /api/form/my/fillable}、{@code /api/form/my/managed}、{@code /api/form/my/capabilities});
+ * - {@code GetSchoolAll}——本校组织树(教师版),管理员两表选人弹窗的数据源;
+ * 守卫从"应用设置 ∪ 表单设计两条模块行"换成 C4③ 的校管理员 ∪ 超管。
+ *
+ *
+ * {@code GetUserMod} 已随模块行整条退役而删除(§19 作废-10):中心不再消费 UserBase 的
+ * 授权模块树,B14(快应用目标应用树缺口)与跨项目触点 H4 随之消失——UserBase 侧不需要为表单中心
+ * 登记任何模块行,也不需要给 {@code /oapi/user/modules} 加 appCode 参数。
*/
-@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 静态客户端。
- */
- static boolean isForeignTarget(String appCode) {
- String target = appCode == null ? "" : appCode.trim();
- return !target.isEmpty() && !APP_CODE.equalsIgnoreCase(target);
- }
-
@GetMapping("/api/base/GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
@@ -59,88 +40,13 @@ public class FormBaseController {
/**
* 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。
*
- * T10 落地的原因:管理员两表的选人入口要"限本校树"(方案 §5.3 末段),而 UserBase 侧
- * 没有给子应用开放"按 app 过滤的组织树"端点,平台现行做法就是子应用自己代理组树——notice 与
- * flow-center 各有一份,本端点是同一口径的第三份({@code SchoolUserTreeService})。
- * 域只从会话推导({@code schId} 不是入参),持应用设置或表单设计任一模块行才能取树。
+ * 没有 {@code schId} 入参:域只从会话推导(契约 §7 身份行),因此候选天然限在本校树里
+ * (跨校的人在接口层就给不出来)。T10 补件的原因是 UserBase 侧没有"按 app 过滤的组织树"端点,
+ * 平台现行做法就是子应用自己代理组树——notice 与 flow-center 各有一份,本端点是同一口径的第三份。
*/
@GetMapping("/api/base/GetSchoolAll")
public List getSchoolAll(BoaUser authUserWithDept) {
- Long domain = adminGuard.requireAdminAny(authUserWithDept, null,
- FormAdminGuard.MODULE_APP_SETTINGS, FormAdminGuard.MODULE_FORM_DESIGN);
+ Long domain = adminGuard.requirePlatformAdmin(authUserWithDept, null);
return schoolUserTreeService.teacherTree(domain);
}
-
- /**
- * 当前用户已授权模块树(左侧菜单)。
- * 依赖 UserBase 侧数据:本应用的 {@code app_module} 行({@code path} 须逐字对上
- * front 路由,见 T5 的 router/routes.ts)+ 角色/组授权,二者缺一即返回空菜单。
- * 这里不保留本地 MENUS 兜底——静默回退会把「平台未配模块/未授权」伪装成正常,
- * 与其他子应用口径一致,配置缺失应表现为空菜单并被发现。
- *
- * @param appCode 可选,目标应用的 app_code。缺省或等于 {@code form-center} 时返回本应用的树;
- * 指向其它应用时走 H4(UserBase 侧尚未上线)——返回空树并打 warn。
- */
- @GetMapping("/api/base/GetUserMod")
- public List
*/
@GetMapping("/submit-list")
public List submitList(BoaUser user) {
@@ -116,6 +121,35 @@ public class FormMyAnswerController {
return fillableService.listFillable(user, appCode);
}
+ /**
+ * 「可填写」清单的管理面同族端点(契约 v2 C3,{@code GET /api/form/my/managed}):
+ * 创建人 ∪ form_admin 命中的行,行形状=fillable 行 + {@code role} 来源标记。
+ *
+ * 它是 D5"菜单自解析"的第二支路数据源。与 {@code /fillable} 一样不接受外部传身份,
+ * 归属判定全在服务侧按会话算({@link FillableFormService#listManaged})。
+ */
+ @GetMapping("/managed")
+ public List managed(BoaUser user,
+ @RequestParam(value = "appCode", required = false) String appCode) {
+ requireSession(user);
+ return fillableService.listManaged(user, appCode);
+ }
+
+ /**
+ * 能力位(契约 v2 §2.4 第三支路):前端"系统设置"入口的唯一数据源。
+ *
+ * 只有 {@code schoolAdmin} 一个布尔——超管恒 true(§2.1)。它不是权限:
+ * 管理员两表、快应用注册、{@code GetSchoolAll} 各自在接口层按 C4③ 的角色门把关,
+ * 这里只回答"要不要给这个人画这个入口"。
+ */
+ @GetMapping("/capabilities")
+ public MyModels.Capabilities capabilities(BoaUser user) {
+ requireSession(user);
+ MyModels.Capabilities caps = new MyModels.Capabilities();
+ caps.setSchoolAdmin(accessPolicy.isSchoolAdmin(user));
+ return caps;
+ }
+
private static void requireSession(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
diff --git a/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java
index 6a95292..40dbcd6 100644
--- a/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java
+++ b/src/main/java/com/yunhai/formcenter/entity/FormAdmin.java
@@ -11,7 +11,7 @@ import javax.persistence.UniqueConstraint;
/**
* 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的数据查看 + 导出授权(D3 裁决,
- * 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。
+ * 编辑权=该表 ALL 档下的管理动作(契约 v2 C4②),本表不扩定义权)。
*
* {@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与
* 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为
diff --git a/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java
index c484bfe..9fab453 100644
--- a/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java
+++ b/src/main/java/com/yunhai/formcenter/entity/SchoolAdmin.java
@@ -13,7 +13,8 @@ import javax.persistence.UniqueConstraint;
* customId 权限锚点行(方案 §7)。
*
*
它只升级数据范围,不是页面入口凭证(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权
- * 模块行判({@code FormAdminGuard}),这张表只让 {@code DataScopeService} 把范围从"仅本人提交"升到
+ * 角色门判({@code FormAdminGuard}+{@code FormAccessPolicy.isSchoolAdmin}),这张表只让 {@code DataScopeService}
+ * 把范围从"SELF"升到
* 本校表单=全部、基础表单=本校提交。
*
* {@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权
diff --git a/src/main/java/com/yunhai/formcenter/model/MyModels.java b/src/main/java/com/yunhai/formcenter/model/MyModels.java
index 327361f..a0d7638 100644
--- a/src/main/java/com/yunhai/formcenter/model/MyModels.java
+++ b/src/main/java/com/yunhai/formcenter/model/MyModels.java
@@ -73,4 +73,42 @@ public final class MyModels {
private Integer fillLimit;
private String groupTag;
}
+
+ /**
+ * 一行"我管理的表单"(契约 v2 C3,{@code GET /api/form/my/managed}):
+ * 行形状=fillable 行 + {@code role} 来源标记数组,菜单自解析的"管理"支路数据源。
+ *
+ *
{@code role} 取值闭集 {@code creator}(C16 判定)/{@code form_admin}(两表命中),
+ * 两个来源同时命中时数组有两个元素——前端要能说出"你为什么管得动这张表"。
+ *
+ * 与 fillable 的两处刻意差别:① 不做遮蔽去重(管理清单要"我能碰的每一张表",
+ * 同 code 的本校草稿与平台级表单是两条不同的可操作资产,藏掉一条就再也进不去);
+ * ② 不限生命周期(草稿/停用行同样要出现在管理支路,否则建了草稿的人找不到入口继续编辑)。
+ */
+ @Getter
+ @Setter
+ public static class ManagedRow extends FillableRow {
+ /**
+ * 表单 uuid(P120 落地差额:C3 只写了"fillable 行 + role",但管理支路没有 uuid 就点不进
+ * 设计器/数据页——数据面与定义面的寻址都是 uuid,code 只作入口)。
+ */
+ private String formUuid;
+ /** 生命周期(0 草稿/1 已发布/2 停用):管理支路要标出"这张还没发布" */
+ private Integer status;
+ /** 创建人=超管/校管理员之外的人,靠这一行拿到继续编辑自己草稿的入口(D6 不设移交) */
+ private List role;
+ }
+
+ /**
+ * 能力位(契约 v2 §2.4 三支路自解析的第三支):前端"系统设置"入口的唯一数据源。
+ *
+ * 只有这一个布尔,是给前端画菜单用的——不是权限:接口侧每个端点各自按 C4 的角色门把关,
+ * 前端藏掉入口不等于没权限(契约 §7 身份行)。
+ */
+ @Getter
+ @Setter
+ public static class Capabilities {
+ /** 校管理员(超管恒 true):管理员两表 / 快应用注册 / GetSchoolAll 的入口位 */
+ private boolean schoolAdmin;
+ }
}
diff --git a/src/main/java/com/yunhai/formcenter/model/ReadModels.java b/src/main/java/com/yunhai/formcenter/model/ReadModels.java
index 27ae189..c244b01 100644
--- a/src/main/java/com/yunhai/formcenter/model/ReadModels.java
+++ b/src/main/java/com/yunhai/formcenter/model/ReadModels.java
@@ -11,7 +11,7 @@ import java.util.List;
import java.util.Set;
/**
- * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。
+ * 数据面读路径(列表/详情/统计/导出)出入参模型(契约 v2 §2.3 两档 + §9,方案 §5.5)。
*
* 三条对外纪律:
*
diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
index ae46372..6fda5dd 100644
--- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
+++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java
@@ -25,6 +25,13 @@ public interface AnswerRepository extends JpaRepository {
Optional findByUuidAndSchId(String uuid, Long schId);
+ /**
+ * 按 (域, uuid 集合) 批量定位(T14 守卫:数据面入口要按答卷反查它所属的表单才能判 ALL 档)。
+ * 带 {@code sch_id} 是同一条租户不变式——跨校 uuid 在这里就查不出来,不给探测面。
+ */
+ @Query("select a from Answer a where a.schId = :schId and a.uuid in (:uuids)")
+ List findBySchIdAndUuidIn(@Param("schId") Long schId, @Param("uuids") Collection uuids);
+
/**
* 两段式列表的第二段(方案 §5.5 铁律①):按当页 id 批量取整文档。
* 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json};
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java
index edaadc8..00e53f6 100644
--- a/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java
+++ b/src/main/java/com/yunhai/formcenter/repository/FormAdminRepository.java
@@ -19,4 +19,10 @@ public interface FormAdminRepository extends JpaRepository {
Optional findByIdAndFormUuid(Long id, String formUuid);
boolean existsByFormUuidAndUserId(String formUuid, Integer userId);
+
+ /**
+ * 按人反查"我被授权了哪些表单"(T14 的 managed 清单,C3)。
+ * {@code idx_form_admin_user} 就是为这条留的(方案 §8 登记的 v2 入口,本批兑现)。
+ */
+ List findByUserId(Integer userId);
}
diff --git a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java
index 7175b98..7fb6e57 100644
--- a/src/main/java/com/yunhai/formcenter/repository/FormRepository.java
+++ b/src/main/java/com/yunhai/formcenter/repository/FormRepository.java
@@ -53,4 +53,17 @@ public interface FormRepository extends JpaRepository
*
- * 两层权限都过(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard},
- * 决定"能不能用数据管理接口"),每条答卷再过一次行级范围——判据是四条读出路径共用的
+ *
两层权限都过(P30 的收紧点,T4 落地;契约 v2 C4② 换了入口判据):控制器先按
+ * 该表单 ALL 档把关({@code FormAdminGuard#requireDataByAnswer(s)},决定"能不能用数据管理接口"),
+ * 每条答卷再过一次行级范围——判据是四条读出路径共用的
* {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此列表里看不见的答卷,
- * 编辑/软删/改状态也做不了:教师即便被授权了数据管理模块行,也只能动自己范围内的行。
+ * 编辑/软删/改状态也做不了:ALL 档入口进来之后,行级仍按同一份范围规格判——"契约 v2 §2.3
+ * 的租户限定变体"下,本校之外的行(含 {@code submitter_sch_id=NULL} 的存量行)照改不动。
*/
@Slf4j
@Service
@@ -210,7 +212,7 @@ public class AnswerAdminService {
new ReadModels.QueryRequest());
if (!queryBuilder.visible(scope, answer.getUuid())) {
throw new BizException(ErrorCode.FORBIDDEN,
- "该答卷不在你的数据范围内(契约 §8 矩阵),不能执行管理动作:" + answerUuid);
+ "该答卷不在你的数据范围内(契约 v2 §2.3 两档),不能执行管理动作:" + answerUuid);
}
return answer;
}
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
index f171ba6..0e34217 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java
@@ -33,7 +33,7 @@ import java.util.List;
import java.util.Map;
/**
- * 数据面读服务(列表 / 详情 / 筛选候选,契约 §8/§9,方案 §5.5)。
+ * 数据面读服务(列表 / 详情 / 筛选候选,契约 v2 §2.3 两档范围 + §6/§9 契约,方案 §5.5)。
*
* 与 {@link AnswerScopeQueryBuilder} 的分工:本类负责"把请求翻译成查询规格"
* (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。
@@ -46,8 +46,8 @@ import java.util.Map;
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。
*
* 末尾的"我的填写"段是另一条线,不要与上面四条混读:那里的可见集来自
- * {@code submitter_id}/{@code subject_id}=会话用户的归属判定,不是契约 §8 的范围矩阵
- * (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点");
+ * {@code submitter_id}/{@code subject_id}=会话用户的归属判定,不是 §2.3 的两档范围
+ * (填表人不进任何管理面也能看自己的数据,方案 §6.3"我的填写两层落点");
* 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。
*/
@Slf4j
@@ -150,7 +150,7 @@ public class AnswerReadService {
Form form = pinned.form();
ReadModels.Query query = scopeQuery(user, domainSchId, form, new ReadModels.QueryRequest());
if (!queryBuilder.visible(query, answer.getUuid())) {
- throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 §8 矩阵):" + answerUuid);
+ throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 v2 §2.3 两档):" + answerUuid);
}
ReadModels.DetailView view = new ReadModels.DetailView();
copyInto(answer, form, view);
@@ -380,9 +380,9 @@ public class AnswerReadService {
* {@code bySubject=false} 按 {@code submitter_id}=会话用户(我提交的),
* {@code bySubject=true} 按 {@code subject_id}=会话用户(关于我的,含管理员代填的那几条,V18)。
*
- * 与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有
- * {@code /admin/data} 模块行;这里不看模块行、不看范围矩阵,只看"这条数据是不是我的",
- * 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。
+ * 与数据管理面的区别:那条路径的范围来自 {@link DataScopeService} 的两档解析且入口按 ALL 档
+ * 把关(契约 v2 C4②);这里不看角色、不看范围,只看"这条数据是不是我的",
+ * 所以填表人(学生)不进任何管理面也能用自己的两视图。
*/
public List listMine(BoaUser user, boolean bySubject) {
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java
index 5fdeb53..c86ec0c 100644
--- a/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java
+++ b/src/main/java/com/yunhai/formcenter/service/AnswerScopeQueryBuilder.java
@@ -29,10 +29,10 @@ import java.util.Map;
import java.util.Set;
/**
- * 数据范围查询构造器(方案 §6.4 红线 / 契约 §8 红线的唯一实现处):
+ * 数据范围查询构造器(方案 §6.4 红线 / 契约 v2 §2.3 红线的唯一实现处):
* 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关,
* 全部调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、
- * 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。
+ * 统计一调返回全校分布,两档范围就白定了),故本类不接受"跳过范围"的开关参数。
*
* 一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)}
* → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询)
diff --git a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
index 32f0d12..c855fee 100644
--- a/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
+++ b/src/main/java/com/yunhai/formcenter/service/DataScopeService.java
@@ -97,9 +97,22 @@ public class DataScopeService {
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。
*/
- private static boolean isCreator(BoaUser user, Form form) {
+ /**
+ * 创建人判定(C16):{@code creator_id}==会话 uid 或 {@code create_by}==会话账号名。
+ *
+ * 兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
+ * 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
+ * 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。
+ *
+ * 公开给 {@link FormAdminGuard} 复用(C4①"定义域写含该表单创建人"与 ALL 档
+ * 必须是同一条判定,两处各写一份就会漂移)。
+ */
+ public static boolean isCreator(BoaUser user, Form form) {
+ if (user == null || user.getId() == null || form == null) {
+ return false;
+ }
if (form.getCreatorId() != null) {
- return user.getId() != null && form.getCreatorId().longValue() == user.getId().longValue();
+ return form.getCreatorId().longValue() == user.getId().longValue();
}
String account = FormAccessPolicy.sessionAccount();
return account != null && !account.trim().isEmpty() && account.trim().equals(form.getCreateBy());
diff --git a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java
index af344d4..19a7df6 100644
--- a/src/main/java/com/yunhai/formcenter/service/FillableFormService.java
+++ b/src/main/java/com/yunhai/formcenter/service/FillableFormService.java
@@ -1,8 +1,10 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.entity.FormAdmin;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.MyModels;
+import com.yunhai.formcenter.repository.FormAdminRepository;
import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
@@ -21,8 +23,9 @@ import java.util.Map;
import java.util.Set;
/**
- * 「我可填」清单解析({@code GET /api/form/my/fillable},契约 v2 C2 行形状):
- * 把"这个会话用户能进哪些表单的填报页"算成一张列表——数据源不再是 UserBase 模块树。
+ * 「我可填」与「我管理的」两张清单({@code GET /api/form/my/fillable} 契约 v2 C2 行形状、
+ * {@code GET /api/form/my/managed} C3):把"这个人能填哪些表、管哪些表"各算成一张列表——
+ * 数据源不再是 UserBase 模块树,两支路都由中心自解析(契约 v2 §2.4/D5)。
*
* 四步,一步不多:
*
@@ -51,6 +54,7 @@ public class FillableFormService {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormRepository formRepository;
+ private final FormAdminRepository formAdminRepository;
/**
* 我可填清单。
@@ -123,4 +127,59 @@ public class FillableFormService {
row.setGroupTag(form.getGroupTag());
return row;
}
+
+ /**
+ * 「我管理的」清单(契约 v2 C3,菜单自解析的"管理"支路):创建人 ∪ form_admin 命中的行。
+ *
+ * 取域与「我可填」同为"会话校 ∪ 平台级",但不做遮蔽去重、不限生命周期
+ * (理由见 {@link MyModels.ManagedRow} 的类注释)。排序沿仓库的 {@code update_time desc}
+ * ——管理动作关心"最近碰过哪张",不像填报清单那样有截止时间这个天然主序。
+ *
+ * @param appCode 可选;非空=只要该应用下的行
+ */
+ public List listManaged(BoaUser user, String appCode) {
+ Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
+ String cleanApp = appCode == null ? "" : appCode.trim();
+ List domain = Form.PLATFORM_SCH_ID == schId
+ ? Collections.singletonList(Form.PLATFORM_SCH_ID)
+ : Arrays.asList(schId, Form.PLATFORM_SCH_ID);
+
+ List administered = new ArrayList<>();
+ for (FormAdmin grant : formAdminRepository.findByUserId(user.getId())) {
+ if (grant.getFormUuid() != null) {
+ administered.add(grant.getFormUuid());
+ }
+ }
+
+ List rows = new ArrayList<>();
+ for (Form form : formRepository.findLiveInDomain(domain)) {
+ if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
+ continue;
+ }
+ List roles = new ArrayList<>();
+ if (DataScopeService.isCreator(user, form)) {
+ roles.add("creator");
+ }
+ if (form.getUuid() != null && administered.contains(form.getUuid())) {
+ roles.add("form_admin");
+ }
+ if (roles.isEmpty()) {
+ continue;
+ }
+ MyModels.ManagedRow row = new MyModels.ManagedRow();
+ row.setFormUuid(form.getUuid());
+ row.setStatus(form.getStatus());
+ MyModels.FillableRow base = toRow(form);
+ row.setCode(base.getCode());
+ row.setName(base.getName());
+ row.setAppCode(base.getAppCode());
+ row.setAudience(base.getAudience());
+ row.setDeadline(base.getDeadline());
+ row.setFillLimit(base.getFillLimit());
+ row.setGroupTag(base.getGroupTag());
+ row.setRole(roles);
+ rows.add(row);
+ }
+ return rows;
+ }
}
diff --git a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java
index 8921c6d..2203e17 100644
--- a/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java
+++ b/src/main/java/com/yunhai/formcenter/service/FormAdminGuard.java
@@ -1,87 +1,240 @@
package com.yunhai.formcenter.service;
-import com.yunhai.formcenter.common.FormModuleKeys;
+import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.repository.AnswerRepository;
+import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
-import java.util.Set;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
/**
- * 管理面守卫(方案 §6.1 第②层 / §6.5 / 执行手册 §2.2)。
+ * 管理面守卫(契约 v2 C4):模块行退役后,这里只剩角色门 + 租户域推导两件事。
*
- * T4 起口径=授权模块集(P21 裁决的到期替换:T2/T3 的 roleId∈{2,7} 硬编码整体移除、不保留回落)——
- * "中心管理员"就是被授权对应模块行的人,与平台其它子应用同构(左导航与后端把关用的是同一棵树,
- * 因此不存在"菜单看得见、接口调不动"的两套口径):
- *
- * - 定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
- * 应用设置={@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 移除角色硬编码时保留它。
- *
+ * 三段判定逐字对齐 C4(《契约v2》§0.4):
+ *
+ * - 定义域——新建/另存副本=超管 ∪ 校管理员(G1 用户裁决定稿的"创建权");
+ * 对某张已存在表单的写(save/publish/disable/enable/softDelete/config/statusDefs)
+ * 与读(list/get/versions/version/filterFields)=超管 ∪ 校管理员 ∪ 该表单创建人
+ * (创建人保留定义权,D6 不设移交);
+ * - 数据域——{@code /admin/data/**} 读写=该表单 ALL 档持有者;
+ * 行级仍由 {@link AnswerScopeQueryBuilder} 把关(V55 唯一出口),本守卫只回答"能不能进这个面";
+ * 原 {@code canManage}(模块行判"有没有代填权")并入同一条 ALL 档判定,由
+ * {@code AnswerService#requireSubject} 消费;
+ * - 平台管理域——管理员两表、app 注册(C17)、{@code GetSchoolAll}=校管理员 ∪ 超管。
+ *
*
- * 与管理权正交的另一条线:数据面能看哪些行由契约 §8 的角色矩阵 + 管理员两表决定
- * ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
- * 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。
- * 管理员两表不充当页面入口凭证(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里
- * 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。
+ * 与 T4~T10 的口径差别:原来"能不能进页面"的事实源在 UserBase 授权模块树
+ * ({@code ModuleTreeSource}),现在在本库的角色与所有权事实里——两条树不复存在,
+ * 也就不再有"菜单看得见、接口调不动"的两套口径(§19 作废-12)。
+ * 租户域推导规则一条没动:超管可显式指定目标域(含 0=平台级),校级只能本校且传 0 直接拒。
+ *
+ * fail-closed:未登录拒;无学校归属拒;表单取不到与"不是你域内的表单"同口径回答
+ * (uuid 不可枚举,存在性本身就是探测面)。
*/
@Service
@RequiredArgsConstructor
public class FormAdminGuard {
- /** 定义管理模块行 path(手册 §2.2:表单设计) */
- public static final String MODULE_FORM_DESIGN = "/admin/forms";
- /** 数据管理模块行 path */
- public static final String MODULE_DATA_MANAGE = "/admin/data";
- /** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */
- public static final String MODULE_STATS = "/admin/stats";
- /**
- * 应用设置模块行 path(T10 方案 §3,取代原字典管理行 {@code /admin/dicts}):
- * 页内两个 tab(角色设置/字典管理)共用这一行,字典 API 也摊它把关。
- */
- public static final String MODULE_APP_SETTINGS = "/admin/app";
-
- private final ModuleTreeSource moduleTreeSource;
+ private final FormAccessPolicy accessPolicy;
+ private final DataScopeService dataScope;
+ private final AnswerRepository answerRepository;
+ private final FormRepository formRepository;
/**
- * 校验"被授权了 {@code modulePath} 这个模块行"并返回本次操作的租户域。
+ * 定义域·新建/另存副本(还没有表单对象可判):超管 ∪ 校管理员(G1)。
*
- * @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行)
- * @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒
- * @param modulePath 需要的模块行 path(本类的四个 {@code MODULE_*} 常量之一)
+ * @param targetSchId 目标域;超管可显式指定(含 0=平台级),校级传入不等本校即拒
*/
- public Long requireAdmin(BoaUser user, Long targetSchId, String modulePath) {
- if (user == null || user.getId() == null) {
- throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
- }
+ public Long requireCreate(BoaUser user, Long targetSchId) {
+ requireSession(user);
if (FormAccessPolicy.isSuperAdmin(user)) {
- if (targetSchId != null) {
- return targetSchId;
+ return superAdminDomain(user, targetSchId);
+ }
+ if (!accessPolicy.isSchoolAdmin(user)) {
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "无新建表单权:新建/另存副本要求超管或校管理员(当前身份 " + describe(user) + ")");
+ }
+ return schoolDomain(user, targetSchId);
+ }
+
+ /**
+ * 定义域·针对某张表单:超管 ∪ 校管理员 ∪ 该表单创建人(C4①/G1)。
+ *
+ * @param form 目标表单(由控制器经 {@code FormDefService#findFormForGuard} 取来);
+ * null=按"还没有表单"的形态处理(同 {@link #requireCreate})
+ */
+ public Long requireDefinition(BoaUser user, Long targetSchId, Form form) {
+ if (form == null) {
+ return requireCreate(user, targetSchId);
+ }
+ requireSession(user);
+ if (FormAccessPolicy.isSuperAdmin(user)) {
+ return superAdminDomain(user, targetSchId);
+ }
+ boolean holder = accessPolicy.isSchoolAdmin(user) || dataScope.isCreator(user, form);
+ if (!holder) {
+ throw new BizException(ErrorCode.FORBIDDEN, "无该表单定义权:需要超管、校管理员或该表单创建人("
+ + "表单 " + form.getAppCode() + "/" + form.getCode() + ",当前身份 " + describe(user) + ")");
+ }
+ Long domain = schoolDomain(user, targetSchId);
+ long formSch = form.getSchId();
+ if (formSch != domain.longValue() && formSch != Form.PLATFORM_SCH_ID) {
+ // 他校表单:与"表单不存在"同口径(存在性差别就是探测面,契约 §1)
+ throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid());
+ }
+ return domain;
+ }
+
+ /**
+ * 数据域({@code /admin/data/**} 读写):该表单 ALL 档持有者(C4②)。
+ *
+ * 校管理员在平台级表单上拿到的是 ALL 档的租户限定变体(D9),照样算 ALL 档持有者——
+ * 进得来,但行集由 {@link AnswerScopeQueryBuilder} 按同一份规格收窄。
+ */
+ public Long requireData(BoaUser user, Long targetSchId, Form form) {
+ requireSession(user);
+ if (FormAccessPolicy.isSuperAdmin(user)) {
+ return superAdminDomain(user, targetSchId);
+ }
+ if (form == null || form.getSchId() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在");
+ }
+ Long domain = schoolDomain(user, targetSchId);
+ long formSch = form.getSchId();
+ if (formSch != domain.longValue() && formSch != Form.PLATFORM_SCH_ID) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid());
+ }
+ if (!dataScope.resolve(user, form).all()) {
+ throw new BizException(ErrorCode.FORBIDDEN, "无该表单数据权:需要 ALL 档"
+ + "(创建人 ∪ 表单管理员 ∪ 校管理员;表单 " + form.getAppCode() + "/" + form.getCode() + ")");
+ }
+ return domain;
+ }
+
+ /** 平台管理域(管理员两表 / app 注册 / GetSchoolAll / 字典):校管理员 ∪ 超管(C4③) */
+ public Long requirePlatformAdmin(BoaUser user, Long targetSchId) {
+ requireSession(user);
+ if (FormAccessPolicy.isSuperAdmin(user)) {
+ return superAdminDomain(user, targetSchId);
+ }
+ if (!accessPolicy.isSchoolAdmin(user)) {
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "无平台管理权:需要校管理员或超管(当前身份 " + describe(user) + ")");
+ }
+ return schoolDomain(user, targetSchId);
+ }
+
+ // ==================== 按答卷寻址的数据面入口(答卷里没有表单坐标) ====================
+
+ /**
+ * 数据面入口·单条答卷:先由答卷反查它所属的表单,再按 {@link #requireData} 判 ALL 档。
+ *
+ * 原形态是"入口按模块行取域、行级由 scope 构造器判";模块行退役后入口判据也是 ALL 档,
+ * 所以必须先把表单找出来——{@code /admin/data/{edit,softDelete,changeStatus}} 与 {@code detail}
+ * 的请求体里只有 {@code answerId}。
+ */
+ public Long requireDataByAnswer(BoaUser user, Long targetSchId, String answerUuid) {
+ Long domain = dataDomain(user, targetSchId);
+ Map byUuid = formsOfAnswers(domain, Collections.singletonList(answerUuid));
+ Form form = answerUuid == null ? null : byUuid.get(answerUuid.trim());
+ return requireData(user, domain, form);
+ }
+
+ /**
+ * 数据面入口·批量:批量里的答卷可能跨表单,所以逐张表都要判 ALL 档,
+ * 任意一张不在档内就整把请求拒(不放进服务里再逐条拒——那样 {@code V14}"填表人不能改状态"
+ * 会从批量通道漏出去)。
+ */
+ public Long requireDataByAnswers(BoaUser user, Long targetSchId, List answerUuids) {
+ Long domain = dataDomain(user, targetSchId);
+ if (FormAccessPolicy.isSuperAdmin(user)) {
+ return domain;
+ }
+ requireSession(user);
+ Map forms = formsOfAnswers(domain, answerUuids);
+ for (String answerUuid : answerUuids) {
+ Form form = answerUuid == null ? null : forms.get(answerUuid.trim());
+ if (form == null) {
+ // 答卷找不到=按"答卷不存在"回答(与读路径同口径),这里交给服务层的 requireLiveAnswer
+ continue;
}
- Long current = FormAccessPolicy.sessionSchId(user);
- if (current != null) {
- return current;
+ if (!dataScope.resolve(user, form).all()) {
+ throw new BizException(ErrorCode.FORBIDDEN, "无该表单数据权:批量里每张表都要求 ALL 档"
+ + "(创建人 ∪ 表单管理员 ∪ 校管理员;卡在 " + form.getAppCode() + "/" + form.getCode() + ")");
}
- throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
+ }
+ return domain;
+ }
+
+ /** 数据面的域推导(不改 requireData 的判定,只是答卷寻址入口要先拿到域才能查答卷) */
+ private Long dataDomain(BoaUser user, Long targetSchId) {
+ requireSession(user);
+ if (FormAccessPolicy.isSuperAdmin(user)) {
+ return superAdminDomain(user, targetSchId);
+ }
+ return schoolDomain(user, targetSchId);
+ }
+
+ /** 两把查询拿回"这批答卷各自属于哪张表"(键=answer uuid,值=头表;查不到的键不出现在结果里) */
+ private Map formsOfAnswers(Long domain, List answerUuids) {
+ List clean = new ArrayList<>();
+ for (String uuid : answerUuids) {
+ if (uuid != null && !uuid.trim().isEmpty()) {
+ clean.add(uuid.trim());
+ }
+ }
+ Map result = new HashMap<>();
+ if (clean.isEmpty()) {
+ return result;
+ }
+ List answers = answerRepository.findBySchIdAndUuidIn(domain, clean);
+ List formUuids = new ArrayList<>();
+ for (Answer answer : answers) {
+ if (answer.getFormUuid() != null && !formUuids.contains(answer.getFormUuid())) {
+ formUuids.add(answer.getFormUuid());
+ }
+ }
+ Map byFormUuid = new HashMap<>();
+ for (Form form : formRepository.findByUuidIn(formUuids)) {
+ byFormUuid.put(form.getUuid(), form);
+ }
+ for (Answer answer : answers) {
+ result.put(answer.getUuid(), byFormUuid.get(answer.getFormUuid()));
+ }
+ return result;
+ }
+
+ // ==================== 租户域推导(T4 起口径原样承继) ====================
+
+ /** 超管:显式目标域优先(含 0=平台级),否则落会话校,都给不出就要求显式指定 */
+ private static Long superAdminDomain(BoaUser user, Long targetSchId) {
+ if (targetSchId != null) {
+ return targetSchId;
}
Long current = FormAccessPolicy.sessionSchId(user);
- if (!hasModule(user, modulePath)) {
- throw new BizException(ErrorCode.FORBIDDEN, "无该模块权限:" + modulePath);
+ if (current != null) {
+ return current;
}
+ throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
+ }
+
+ /** 校级:会话校唯一合法域;平台级行与别校域一律拒 */
+ private static Long schoolDomain(BoaUser user, Long targetSchId) {
+ Long current = FormAccessPolicy.sessionSchId(user);
if (current == null) {
- // 授权树是按 (userId, schId 上下文) 取的,无学校归属时返回的树不可信;
- // 更关键的是这里必须返回一个"本校"域,给不出就不能放行
+ // 角色判定可能来自天然校领导(无学校归属的身份给不出"本校"域),这里必须拒而不是放行
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定管理域");
}
if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) {
- // 平台级行=跨校资产:校级管理员把目标域传成 0,先按"平台级不归学校管"讲清楚,
- // 而不是笼统说一句"只能管本校"(同一条拒绝,原因要说准)
throw new BizException(ErrorCode.FORBIDDEN, "平台级表单/字典仅超管可维护");
}
if (targetSchId != null && !targetSchId.equals(current)) {
@@ -90,102 +243,14 @@ public class FormAdminGuard {
return current;
}
- /**
- * 是否具备管理权(超管,或被授权定义管理/数据管理任一模块行)。不含租户域核对——
- * 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。
- *
- * 现用途=契约 §5 的代填分支({@code subject_id} 能否指向他人):普通用户只能=自己,
- * 有管理权者可代填(方案 §6.3/V17)。口径与 P21 过渡期的 roleId∈{2,7} 等宽(定义或数据管理),
- * 判定依据换成授权模块集。
- */
- public boolean canManage(BoaUser user) {
- if (user == null || user.getId() == null) {
- return false;
- }
- if (FormAccessPolicy.isSuperAdmin(user)) {
- return true;
- }
- if (FormAccessPolicy.sessionSchId(user) == null) {
- return false;
- }
- Set paths = authorizedPaths(user);
- return paths.contains(MODULE_DATA_MANAGE) || paths.contains(MODULE_FORM_DESIGN);
- }
-
- /**
- * 同一条校验的"任一模块行命中即放行"形态。
- *
- * 现用途=统计:手册 §2.2 明示统计行"可并入数据管理,二选一",所以两种配法都算有统计权;
- * 只有一个模块行要求的接口请用 {@link #requireAdmin}。
- */
- public Long requireAdminAny(BoaUser user, Long targetSchId, String... modulePaths) {
+ private static void requireSession(BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
- if (modulePaths == null || modulePaths.length == 0) {
- throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模块行清单为空(调用方写错了)");
- }
- if (FormAccessPolicy.isSuperAdmin(user)) {
- return requireAdmin(user, targetSchId, modulePaths[0]);
- }
- Set held = authorizedPaths(user);
- for (String modulePath : modulePaths) {
- if (held.contains(modulePath)) {
- // 命中即按该模块行走完整租户域校验(复用同一段拒绝逻辑,不在这里另写一份域判定)
- return requireAdmin(user, targetSchId, modulePath);
- }
- }
- throw new BizException(ErrorCode.FORBIDDEN,
- "无该模块权限(需任一):" + 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 (FormAccessPolicy.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 = FormAccessPolicy.sessionSchId(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));
- }
-
- /** 是否被授权某个模块行(path 精确匹配;超管直接放行) */
- private boolean hasModule(BoaUser user, String modulePath) {
- return FormAccessPolicy.isSuperAdmin(user) || authorizedPaths(user).contains(modulePath);
+ /** 拒绝文案里的身份要素(可诊断:是角色不够还是身份没带对) */
+ private static String describe(BoaUser user) {
+ return "id=" + user.getId() + " type=" + user.getType() + " roleIds=" + user.getRoleIds();
}
}
diff --git a/src/main/java/com/yunhai/formcenter/service/FormDefService.java b/src/main/java/com/yunhai/formcenter/service/FormDefService.java
index 42ed83a..140df4a 100644
--- a/src/main/java/com/yunhai/formcenter/service/FormDefService.java
+++ b/src/main/java/com/yunhai/formcenter/service/FormDefService.java
@@ -274,7 +274,7 @@ public class FormDefService {
/**
* 数据面读接口入口把关用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定,
- * 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}——
+ * 取不到返回 null。域与角色的判断全在 {@code FormAdminGuard#requireData}——
* 那里才知道"他校表单与不存在的表单要用同一口径回答",本方法刻意不给拒绝话术。
*/
public Form findFormForGuard(String formUuid) {
diff --git a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java
deleted file mode 100644
index a794488..0000000
--- a/src/main/java/com/yunhai/formcenter/service/ModuleTreeSource.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.platform.utils.model.BoaModule;
-import com.yunhai.platform.utils.model.BoaUser;
-
-import java.util.List;
-
-/**
- * 会话用户在本应用下的已授权模块树来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
- *
- * 本树回答管理权一类问题(执行手册 §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 装配的出站链负责);
- * 本地 IT 不启动 UserBase/CAS,注入桩即可断言权限矩阵本身,真实链路 T9 联调验。
- *
- * 失败口径=fail-closed:不可达或异常按"无任何授权模块"处理(管理权被拒、范围回落到仅本人提交),
- * 与契约 §8"全不命中=仅本人提交(宁紧勿松)"同向。
- */
-public interface ModuleTreeSource {
-
- /** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */
- List authorizedModules(BoaUser user);
-}
diff --git a/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java b/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java
deleted file mode 100644
index dddf885..0000000
--- a/src/main/java/com/yunhai/formcenter/service/PlatformModuleTreeSource.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.platform.openapi.base.YschBaseOpenApi;
-import com.yunhai.platform.utils.model.BoaModule;
-import com.yunhai.platform.utils.model.BoaUser;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import java.util.Collections;
-import java.util.List;
-
-/**
- * {@link ModuleTreeSource} 的生产实现:平台标准签名调用 UserBase {@code /oapi/user/modules}
- * (经 SDK {@code YschBaseOpenApi.getUserModule(userId, schId)},与前端壳 GetUserMod 同一条链路、
- * 同一个数据源——管理权与导航菜单因此不会有两套口径,方案 §6.1 运行时链路)。
- *
- * {@code schId} 传会话学校=把授权限定在该学校上下文(UserBase 侧只取该校子树内的真实归属组参与判定);
- * 无学校归属的身份(区域组/根直属)传 null,按 UserBase 语义取全部归属组。
- */
-@Slf4j
-@Service
-public class PlatformModuleTreeSource implements ModuleTreeSource {
-
- @Override
- public List authorizedModules(BoaUser user) {
- if (user == null || user.getId() == null) {
- return Collections.emptyList();
- }
- Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
- try {
- List modules = YschBaseOpenApi.getUserModule(user.getId(), schId);
- return modules == null ? Collections.emptyList() : modules;
- } catch (Exception e) {
- // fail-closed:取不到树就当没有授权——管理权拒、数据范围回落到仅本人提交(接口注释)
- log.warn("[模块树] UserBase /oapi/user/modules 不可达,按无授权处理 userId={} schId={}:{}",
- user.getId(), schId, e.getMessage());
- return Collections.emptyList();
- }
- }
-}
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 9fd544d..97b7524 100644
--- a/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java
+++ b/src/test/java/com/yunhai/formcenter/controller/api/FormBaseControllerTest.java
@@ -1,123 +1,58 @@
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.DisplayName;
import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.GetMapping;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
+import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
/**
- * GetUserMod 映射与 target appCode 判定单测(本地 {@code mvn test} 即门禁,CI 不跑单测)。
- * 映射部分锁定三个易错点:modId 取数字 custom_id、children 恒为数组(前端无判空递归)、
- * path 缺失归空串。appCode 部分锁定「指向别的应用时绝不打 SDK」——
- * 单测里 SDK 静态客户端未装配,误走本应用分支会直接抛异常而不是返回空树。
+ * 前端壳基础端点单测(契约 v2 §2.4 / 验收 V72)。
+ *
+ * 原 {@code GetUserMod} 的 BoaModule 树映射与"外指 appCode 空树不回落"(P3 裁决)两批用例
+ * 随端点退役整体删除——模块行整条退役后中心不再消费授权树,那套映射没有调用方了
+ * (§19 作废-10;B14 缺口与跨项目触点 H4 一并消失)。
+ *
+ * 留两条断言:① {@code GetUserInfo} 仍原样回话会话用户(App.vue 引导的唯一请求);
+ * ② 反射级"端点真拆"断言——{@code /api/base/GetUserMod} 这个映射在控制器上不存在。
+ * 拆面必须真拆(任务书 §1 红线 10):留着空实现或只删调用方都算双轨。
*/
+@DisplayName("FormBaseController - 壳基础端点与 GetUserMod 退役(V72)")
class FormBaseControllerTest {
@Test
- void toFrontendTree_shouldKeepServerHierarchyAndRenameFields() {
- BoaModule parent = module(501, "100", "/admin/forms", 1);
- BoaModule child = module(502, "101", "/admin/data", 2);
- parent.setChildren(new ArrayList<>(Collections.singletonList(child)));
- BoaModule otherRoot = module(503, "105", "/my", 1);
-
- List> tree = FormBaseController.toFrontendTree(Arrays.asList(parent, otherRoot));
-
- assertEquals(2, tree.size(), "顶层只应有两个根,子节点不得浮到顶层");
- Map first = tree.get(0);
- assertEquals("/admin/forms", first.get("page"), "path → page(前端按 page 匹配菜单高亮)");
- assertEquals(100, first.get("modId"), "modId 必须取数字 custom_id 而非主键 id");
- assertEquals(501, first.get("id"), "原始主键仍经 id 字段透出");
-
- @SuppressWarnings("unchecked")
- List> children = (List>) first.get("children");
- assertEquals(1, children.size(), "层级应原样保留");
- assertEquals(101, children.get(0).get("modId"));
- }
-
- @Test
- void toFrontendTree_shouldAlwaysEmitChildrenAsArray() {
- BoaModule configuredLeaf = module(601, "102", "/admin/app", 1);
- configuredLeaf.setChildren(new ArrayList<>());
- BoaModule notExpanded = module(602, "103", "/admin/stats", 1); // 上游未展开 → children 为 null
-
- List> tree = FormBaseController.toFrontendTree(Arrays.asList(configuredLeaf, notExpanded));
-
- for (Map node : tree) {
- assertNotNull(node.get("children"), "children 不得为 null,前端要无判空递归");
- assertTrue(((List>) node.get("children")).isEmpty(), "无子节点时应为空列表");
- }
- }
-
- @Test
- void toFrontendTree_shouldFallBackToPrimaryKeyWhenCustomIdNotNumeric() {
- // 手册 §2.2 v1.5 起中心五行的 customId 恒为数字(200~204),非数字只是上游配错时的兜底:
- // 映射不炸、modId 退回主键、path 空行给空串(前端导航按 path 空跳过,快应用按钮锚点同形态)
- BoaModule misconfigured = module(701, "not-a-number", null, 1);
-
- List> tree = FormBaseController.toFrontendTree(Collections.singletonList(misconfigured));
-
- assertEquals(701, tree.get(0).get("modId"), "custom_id 非数字时回退模块主键 id");
- assertEquals("", tree.get(0).get("page"), "path 缺失应给空串而非 null(前端字符串比较不判空)");
- }
-
- @Test
- void toFrontendTree_shouldTolerateEmptyAndNullInput() {
- assertTrue(FormBaseController.toFrontendTree(Collections.emptyList()).isEmpty());
- assertTrue(FormBaseController.toFrontendTree(null).isEmpty(), "上游返回 null 不得抛 NPE");
- }
-
- @Test
- void isForeignTarget_shouldOnlyTreatOtherAppCodesAsForeign() {
- assertFalse(FormBaseController.isForeignTarget(null), "缺省=本应用");
- assertFalse(FormBaseController.isForeignTarget(" "), "空白=缺省");
- assertFalse(FormBaseController.isForeignTarget("form-center"), "显式等于本应用仍走本应用分支");
- assertFalse(FormBaseController.isForeignTarget(" Form-Center "), "比对忽略大小写与首尾空白");
- assertTrue(FormBaseController.isForeignTarget("honor"), "快应用 app_code 才是目标应用分支");
- }
-
- @Test
- void getUserMod_shouldReturnEmptyTreeForForeignTargetWithoutTouchingSdk() {
+ void getUserInfo_shouldReturnSessionUserAsIs() {
BoaUser user = new BoaUser();
user.setId(10133);
+ user.setName("李老师");
- List> tree = controller().getUserMod(user, "honor");
+ BoaUser back = new FormBaseController(null, null).getUserInfo(user);
- assertTrue(tree.isEmpty(), "H4(UserBase appCode 参数)未上线,目标应用一律空树");
+ assertSame(user, back, "会话用户原样下发,不在这里裁剪字段(前端只取 id/name/avatar)");
+ assertEquals(Integer.valueOf(10133), back.getId());
}
@Test
- void getUserMod_shouldReturnEmptyTreeWithoutSessionUser() {
- assertTrue(controller().getUserMod(null, null).isEmpty());
- }
-
- /**
- * 只覆盖静态映射与外指分支的控制器实例:这两条路径都不碰注入的协作者,
- * 组织树服务给 null 即可(真树在 SchoolUserTreeServiceTest 与真会话轮取证)。
- */
- private static FormBaseController controller() {
- return new FormBaseController(
- new FormAdminGuard(session -> Collections.emptyList()), null);
- }
-
- private static BoaModule module(Integer id, String customId, String path, Integer level) {
- BoaModule m = new BoaModule();
- m.setId(id);
- m.setCustomId(customId);
- m.setName("模块" + customId);
- m.setPath(path);
- m.setLevel(level);
- m.setSortNo(level * 10);
- return m;
+ void getUserModMappingShouldNoLongerExist() {
+ long gets = 0;
+ for (Method method : FormBaseController.class.getDeclaredMethods()) {
+ GetMapping get = method.getAnnotation(GetMapping.class);
+ if (get == null || method.isSynthetic()) {
+ continue;
+ }
+ gets++;
+ for (String path : get.value()) {
+ if (path.toLowerCase().contains("getusermod")) {
+ throw new AssertionError("GetUserMod 端点仍在(模块行未真拆):" + path);
+ }
+ }
+ }
+ // 只留两个端点:GetUserInfo 与 GetSchoolAll
+ assertEquals(2, gets, "壳基础端点应当只剩 GetUserInfo 与 GetSchoolAll");
}
}
diff --git a/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java b/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java
index 68ed474..4b3b498 100644
--- a/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AdminDomainIT.java
@@ -3,6 +3,7 @@ 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.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName;
@@ -30,7 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@Import(AnswerTestSupport.TestGatewayConfiguration.class)
@Transactional
-@DisplayName("管理员两表管理面 - 幂等/防呆/跨校(V45)与应用设置把关(V46)")
+@DisplayName("管理员两表管理面 - 幂等/防呆/跨校(V45)与平台管理域把关(V72)")
class AdminDomainIT extends AnswerTestSupport {
@Autowired
@@ -163,31 +164,32 @@ class AdminDomainIT extends AnswerTestSupport {
.isInstanceOf(BizException.class).hasMessageContaining("不属于本次管理域");
}
- // ==================== V46:应用设置行把关 ====================
+ // ==================== V72:平台管理域把关(取代原 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);
+ @DisplayName("V72 把关:两表维护面按 C4③ 角色门(校管理员 ∪ 超管),不再消费任何 UserBase 模块行")
+ void platformAdminRoleIsTheGate() {
+ // 天然校领导(roleId 7)在校管理员补授表里没有任何行,也照样进得来——兜底那半是 §2.2 的语义
+ BoaUser leader = schoolUser();
+ assertThat(adminGuard.requirePlatformAdmin(leader, null)).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);
- }
+ BoaUser teacher = new BoaUser();
+ teacher.setId(USER_SEQ.incrementAndGet());
+ teacher.setType(0);
+ teacher.setRoleIds(new ArrayList<>(Collections.singletonList(1)));
+ BoaRoleInfo current = new BoaRoleInfo();
+ current.setRoleId(1);
+ current.setSchId((int) SCH);
+ teacher.setCurrentRole(current);
+ assertThatThrownBy(() -> adminGuard.requirePlatformAdmin(teacher, null))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("无平台管理权");
+ });
- @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);
+ // school_admin 表是补授通道:进去了就是校管理员,能进维护面
+ grantSchoolAdmin(teacher.getId(), SCH);
+ assertThat(adminGuard.requirePlatformAdmin(teacher, null)).isEqualTo(SCH);
}
// ==================== 小工具 ====================
@@ -199,8 +201,8 @@ class AdminDomainIT extends AnswerTestSupport {
return item;
}
- /** 只授权指定模块行的会话用户(roleId=7 但模块行另算——P21 起角色不给管理权);每次一个独立 id,防同测试内两个反例串权限 */
- private BoaUser schoolUserWith(String... paths) {
+ /** 校管理员形态的会话用户(roleId=7 天然命中校领导临时清单,两表里刻意不留行——测的就是"天然"那半) */
+ private BoaUser schoolUser() {
BoaUser user = new BoaUser();
user.setId(USER_SEQ.incrementAndGet());
user.setType(0);
@@ -209,7 +211,6 @@ class AdminDomainIT extends AnswerTestSupport {
current.setRoleId(7);
current.setSchId((int) SCH);
user.setCurrentRole(current);
- moduleTree.grantPaths(user.getId(), paths);
return user;
}
}
diff --git a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
index 095c448..c08a415 100644
--- a/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AdminScopeIT.java
@@ -144,37 +144,35 @@ class AdminScopeIT extends AnswerTestSupport {
}
@Test
- @DisplayName("V44 权利=查看+导出:导出行数与列表同集;管理面入口仍要模块行(两表不给维护权)")
- void formAdminGetsReadAndExportButNotMaintenance() {
+ @DisplayName("V44 权利=查看+导出:form_admin 的导出行数与列表同集(V27 同一条构造器)")
+ void formAdminExportMatchesList() {
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() {
+ @DisplayName("C4/G1 边界:form_admin 只升数据面——数据面入口进得来,定义面与平台管理面照样拒")
+ void formAdminOpensDataFaceOnly() {
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("无该模块权限");
+ Form own = defService.findFormForGuard(seed.ownUuid);
+
+ // 数据面读写入口=该表单 ALL 档(C4②),form_admin 命中即持有——模块行那套入口凭证已退役
+ assertThat(adminGuard.requireData(teacher, SCH, own)).isEqualTo(SCH);
+
+ // 定义域写(表单设计)与平台管理域(两表/应用设置)都要求超管∪校管理员(创建人另算,教师不是)
+ assertThatThrownBy(() -> adminGuard.requireDefinition(teacher, SCH, own))
+ .isInstanceOf(BizException.class).hasMessageContaining("无该表单定义权");
+ assertThatThrownBy(() -> adminGuard.requirePlatformAdmin(teacher, null))
+ .isInstanceOf(BizException.class).hasMessageContaining("无平台管理权");
}
// ==================== V47:加列 ====================
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java
index 87b591e..c268b36 100644
--- a/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerAdminIT.java
@@ -9,6 +9,7 @@ import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.StatusLogRepository;
import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -236,12 +237,16 @@ class AnswerAdminIT extends AnswerTestSupport {
req.setAnswerId(answerId);
req.setStatus("done");
- // 控制器走的就是这一行:无管理权的填表人到不了 service
+ // 控制器走的就是这一行(C4② 数据面入口=该表单 ALL 档):无 ALL 档的填表人到不了 service
for (BoaUser submitter : Arrays.asList(plainUser(3), plainUser(1))) {
- assertThatThrownBy(() -> adminGuard.requireAdmin(submitter, null, FormAdminGuard.MODULE_DATA_MANAGE))
- .isInstanceOf(BizException.class).hasMessageContaining("无该模块权限");
+ assertThatThrownBy(() -> adminGuard.requireDataByAnswer(submitter, null, answerId))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("无该表单数据权");
+ });
}
- assertThat(adminGuard.requireAdmin(adminUser(), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
+ // 校管理员(roleId 7 命中校领导临时清单)= ALL 档,进得来
+ assertThat(adminGuard.requireDataByAnswer(adminUser(), null, answerId)).isEqualTo(SCH);
}
// ==================== 批量 ====================
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java
index 1aa3bfc..88d220c 100644
--- a/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerReadIT.java
@@ -399,17 +399,25 @@ class AnswerReadIT extends AnswerTestSupport {
.isInstanceOf(BizException.class).hasMessageContaining("不在你的数据范围内");
assertThatThrownBy(() -> readService.detail(manager(), OTHER_SCH, rows.teacher1Row))
.isInstanceOf(BizException.class).hasMessageContaining("答卷不存在");
- // 教师被授权数据管理模块行也改不动别人的行(V39 范围半边:批量逐条判)
+ // V39 的范围半边换了载体:入口按 ALL 档把关(C4②),进得来之后逐条仍过同一条范围构造器。
+ // 同一张表上"ALL 档持有者改别人的行"从此是**允许**的(form_admin 的语义就是该表全部行),
+ // 所以逐条判的可达形态只剩两种——①混入不存在/别域的 id(下面这条),
+ // ②租户限定变体把存量 NULL 行挡在行集外(AdminScopeIT.schoolAdminSeesOnlyOwnSchoolAnswersOnBaseForm)。
BoaUser teacher = fixture.teacher1();
- moduleTree.grantPaths(ReadFixture.TEACHER_1, FormAdminGuard.MODULE_DATA_MANAGE);
- adminGuard.requireAdmin(teacher, null, FormAdminGuard.MODULE_DATA_MANAGE);
+ grantFormAdmin(rows.formAUuid, ReadFixture.TEACHER_1, SCH);
+ adminGuard.requireDataByAnswers(teacher, SCH, Arrays.asList(rows.teacher1Row, rows.teacher2Row));
AnswerModels.BatchRequest batch = new AnswerModels.BatchRequest();
- batch.setAnswerIds(Arrays.asList(rows.teacher1Row, rows.teacher2Row));
+ String ghost = "ffffffffffffffffffffffffffffffff";
+ batch.setAnswerIds(Arrays.asList(rows.teacher1Row, ghost, rows.teacher2Row));
batch.setStatus("pending");
List results = adminService.batchChangeStatus(teacher, SCH, batch);
- assertThat(results.get(0).getSuccess()).isTrue();
- assertThat(results.get(1).getSuccess()).isFalse();
- assertThat(results.get(1).getMsg()).contains("不在你的数据范围内");
+ assertThat(results.get(0).getSuccess()).as("ALL 档持有者改别人的行=允许(该表全部行)").isTrue();
+ assertThat(results.get(1).getSuccess()).as("不存在的 id 只让那一条失败").isFalse();
+ assertThat(results.get(1).getMsg()).contains("答卷不存在或已删除");
+ assertThat(results.get(2).getSuccess()).isTrue();
+ // 教师的 SELF/ALL 判定不因这次批量而变宽:他管的还是这张表(form_admin 只授到该 uuid)
+ assertThatThrownBy(() -> adminGuard.requireDataByAnswer(teacher, SCH, rows.formBUuid))
+ .isInstanceOf(BizException.class);
}
// ==================== P25:遮蔽后的钉版溯源 ====================
diff --git a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java
index e061cd0..b1df020 100644
--- a/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java
+++ b/src/test/java/com/yunhai/formcenter/service/AnswerTestSupport.java
@@ -27,10 +27,10 @@ import java.util.concurrent.atomic.AtomicInteger;
* 测试直接造它——这正是 V17 的断言方式:服务签名里只有 {@code BoaUser} 与请求体,
* 请求体没有 {@code schId/submitterId} 字段,身份只能来自会话对象。
*
- * 两个外部事实源用桩顶掉({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS):
- * {@link ModuleTreeSource}(授权模块集=P21 的管理权口径 + customId 范围升级位)与
- * {@link SubjectDirectory}(P33 的代填存在性)。生产实现是平台签名调用,接线在 T9 联调验;
- * 本轮取证的是"规则本身对不对",桩把事实源钉住即可。
+ * 外部事实源用桩顶掉({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS):
+ * {@link SubjectDirectory}(P33 的代填/代录存在性,生产实现是平台签名调用)。
+ * 契约 v2 之后没有第三个桩了:原 {@code ModuleTreeSource}(授权模块集)随模块行整条退役删除,
+ * 管理权与数据范围的事实源都落在本库(角色 + 两表 + creator_id),IT 直接插真行。
*/
abstract class AnswerTestSupport {
@@ -60,9 +60,6 @@ abstract class AnswerTestSupport {
@Autowired
protected AnswerIndexService indexService;
- @Autowired
- protected InMemoryModuleTreeSource moduleTree;
-
@Autowired
protected InMemorySubjectDirectory subjects;
@@ -74,7 +71,6 @@ abstract class AnswerTestSupport {
@BeforeEach
void resetGateways() {
- moduleTree.reset();
subjects.reset();
}
@@ -101,20 +97,17 @@ abstract class AnswerTestSupport {
}
/**
- * 管理员操作人(roleId=7 + 被授权数据管理/定义管理模块行)。
+ * 管理员操作人(契约 v2 C4 之后=roleId 7 命中校领导临时清单,天然校管理员)。
*
- * T4 起管理权看授权模块集(P21 口径),单有 roleId 不再等于能管理——所以"管理员"在测试里
- * 就是这两件事一起造。需要"管理员但没被授权"的反例时用 {@link #user(Integer, Long, Integer)}
- * 再 {@code moduleTree.clear(id)}。
+ * 模块行退役后没有"再授权一次"的第二件事:管理权与数据范围来自同一份角色/所有权事实。
+ * 需要"不是校管理员"的反例时用 {@link #user(Integer, Long, Integer)} 配一个不在清单里的 roleId。
*/
protected BoaUser manager() {
return manager(MANAGER_ID);
}
protected BoaUser manager(Integer id) {
- BoaUser user = user(id, SCH, 7, 2);
- moduleTree.grantManageDefaults(id);
- return user;
+ return user(id, SCH, 7, 2);
}
/** 指定 id 的会话用户(并发/限次用例需要"同一个人"稳定复现) */
@@ -127,10 +120,6 @@ abstract class AnswerTestSupport {
current.setRoleId(roleId);
current.setSchId(schId.intValue());
user.setCurrentRole(current);
- if (roleId != null && roleId == 7) {
- // 管理员在测试语义里=被授权中心模块行的人(手册 §2.2);反例显式 clear
- moduleTree.grantManageDefaults(id);
- }
return user;
}
@@ -228,12 +217,6 @@ abstract class AnswerTestSupport {
@TestConfiguration
static class TestGatewayConfiguration {
- @Bean
- @Primary
- InMemoryModuleTreeSource inMemoryModuleTreeSource() {
- return new InMemoryModuleTreeSource();
- }
-
@Bean
@Primary
InMemorySubjectDirectory inMemorySubjectDirectory() {
diff --git a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java
index 84585d9..3d78bdb 100644
--- a/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java
+++ b/src/test/java/com/yunhai/formcenter/service/FormAdminGuardTest.java
@@ -1,269 +1,302 @@
package com.yunhai.formcenter.service;
+import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form;
+import com.yunhai.formcenter.repository.AnswerRepository;
+import com.yunhai.formcenter.repository.FormRepository;
import com.yunhai.platform.apistd.BizException;
-import com.yunhai.platform.utils.model.BoaModule;
+import com.yunhai.platform.utils.exception.ErrorCode;
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 java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowableOfType;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
/**
- * 管理权闸门单测(执行手册 §5 P21 裁决的 T4 到期替换:授权模块集口径,roleId 硬编码整体移除;
- * T10 再改:字典管理行撤、应用设置行接手 + 数据面入口判定 {@code requireDataView})。
+ * 管理面守卫单测(契约 v2 C4,验收 V72 的守卫半边)。
*
- * 口径:能进哪个管理面=被授权哪个模块行(手册 §2.2 表 v1.5 五行版:定义管理 {@code /admin/forms}、
- * 数据管理 {@code /admin/data}、统计 {@code /admin/stats}、应用设置 {@code /admin/app}、我的填写
- * {@code /my});超管({@code type==1},UserBase 用户类型不是 roleId)仍可跨校且唯一可管
- * {@code sch_id=0} 平台级行(方案 §6.5"超管不受单校开通约束")。
+ * 原 T4~T10 形态的"授权模块行→能不能进页面"用例({@code ModuleTreeSource} 桩 + path 匹配)
+ * 随模块行整条退役删除(§19 作废-10)。这里改按 C4 三段角色门逐格断言,并且把租户域推导
+ * 一并钉住——那半套规则(超管可指定含 0 的目标域、校级只能本校且传 0 即拒)从 T4 起一字未动,
+ * 换的只是"进不进得来"的判据来源。
*
- * 桩={@link ModuleTreeSource} 的 lambda——本类不启 Spring、不连库,验证的是"给定授权集合,
- * 判定对不对"。真实树来自 UserBase 的链路在 T9 联调验。
+ * 两表与创建人判定都用真实现({@link InMemoryAdminScopeSource} + {@link DataScopeService}),
+ * 只有"按答卷反查表单"用到仓库(Mockito 桩)——守卫的逻辑本身一行都不允许是桩。
*/
-@DisplayName("FormAdminGuard - 授权模块集口径与数据面入口判定(P21/T10)")
+@DisplayName("FormAdminGuard - C4 三段角色门与租户域推导(V72)")
class FormAdminGuardTest {
- private static final long SCH = 880001L;
- private static final long OTHER_SCH = 880002L;
+ private static final long SCH = 990301L;
+ private static final long OTHER_SCH = 990302L;
+ private static final String FORM_UUID = "f-uuid-0001";
+
+ private final InMemoryAdminScopeSource admins = new InMemoryAdminScopeSource();
+ private final FormAccessPolicy policy = new FormAccessPolicy(admins);
+ private final AnswerRepository answerRepository = mock(AnswerRepository.class);
+ private final FormRepository formRepository = mock(FormRepository.class);
+ private final DataScopeService dataScope = new DataScopeService(
+ Arrays.asList(new AllScopeImplementor(), new SelfScopeImplementor()), admins, policy);
+ private final FormAdminGuard guard =
+ new FormAdminGuard(policy, dataScope, answerRepository, formRepository);
+
+ // ==================== C4① 定义域 ====================
@Test
- @DisplayName("被授权数据管理模块行=可进数据面;同 roleId 但未授权=拒(管理权不再来自角色)")
- void moduleGrantIsTheGate() {
- FormAdminGuard guard = guardWith("/admin/data");
- assertThat(guard.requireAdmin(schoolUser(7), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
- // 教师被授权了数据管理行同样能进(第②层与第③层正交:能进接口≠能看全部行,行级范围另判)
- assertThat(guard.requireAdmin(schoolUser(1), null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
+ @DisplayName("新建/另存副本=超管 ∪ 校管理员(G1);教师/学生/家长一律拒,且拒绝文案点名要求的角色")
+ void createRequiresSuperOrSchoolAdmin() {
+ assertThat(guard.requireCreate(superAdmin(), null)).isEqualTo(0L);
+ assertThat(guard.requireCreate(user(7), null)).isEqualTo(SCH);
+ assertThat(guard.requireCreate(user(2), null)).isEqualTo(SCH);
+ admins.grantSchoolAdmin(SCH, 30003);
+ assertThat(guard.requireCreate(user(3), SCH)).isEqualTo(SCH);
- FormAdminGuard none = guardWith();
- for (int roleId : Arrays.asList(1, 2, 3, 5, 6, 7)) {
- assertThatThrownBy(() -> none.requireAdmin(schoolUser(roleId), null,
- FormAdminGuard.MODULE_DATA_MANAGE))
- .as("roleId=" + roleId + " 未授权模块行")
- .isInstanceOf(BizException.class)
- .hasMessageContaining("无该模块权限");
+ for (int roleId : new int[]{1, 4, 5, 6}) {
+ admins.reset();
+ assertThatThrownBy(() -> guard.requireCreate(user(roleId), null))
+ .as("roleId=" + roleId)
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("无新建表单权").contains("超管或校管理员");
+ });
}
}
@Test
- @DisplayName("四个模块行互不通用:只有定义管理权的人调应用设置面(含字典 API)被拒(口径=手册 §2.2 一行一权)")
- void modulesAreNotInterchangeable() {
- FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_FORM_DESIGN);
- assertThatCode(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_FORM_DESIGN))
- .doesNotThrowAnyException();
- assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_APP_SETTINGS))
- .hasMessageContaining(FormAdminGuard.MODULE_APP_SETTINGS);
- assertThatThrownBy(() -> guard.requireAdmin(schoolUser(2), null, FormAdminGuard.MODULE_STATS))
- .hasMessageContaining(FormAdminGuard.MODULE_STATS);
+ @DisplayName("C4①:创建人(creator_id 命中)对本人所建表单有定义权,即便两表里根本没有他")
+ void creatorHoldsDefinitionOnOwnForm() {
+ BoaUser teacher = user(1);
+ // 先坐实:教师身份 + 别人建的表 = 定义域进不去(创建人那半不成立)
+ assertThatThrownBy(() -> guard.requireDefinition(teacher, SCH, form(SCH, 12345)))
+ .isInstanceOf(BizException.class).hasMessageContaining("无该表单定义权");
+
+ Form own = form(SCH, 12345);
+ own.setCreatorId(Long.valueOf(teacher.getId()));
+ assertThat(guard.requireDefinition(teacher, SCH, own)).isEqualTo(SCH);
}
@Test
- @DisplayName("统计的\"二选一\"配法:授权 /admin/stats 或 /admin/data 任一即可(手册 §2.2 统计可并入数据管理)")
- void statsAcceptsEitherModuleRow() {
- assertThat(guardWith(FormAdminGuard.MODULE_STATS)
- .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
- FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
- assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE)
- .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
- FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
- assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_APP_SETTINGS)
- .requireAdminAny(schoolUser(7), null, FormAdminGuard.MODULE_STATS,
- FormAdminGuard.MODULE_DATA_MANAGE))
- .isInstanceOf(BizException.class).hasMessageContaining("需任一");
+ @DisplayName("C4① 兜底:creator_id 未回填的存量行按 create_by 账号名判(无会话账号时不成立,宁紧勿松)")
+ void legacyCreateByFallbackNeedsSessionAccount() {
+ BoaUser teacher = user(1);
+ Form legacy = form(SCH, teacher.getId());
+ legacy.setCreatorId(null);
+ legacy.setCreateBy("some_account");
+ assertThatThrownBy(() -> guard.requireDefinition(teacher, SCH, legacy))
+ .isInstanceOf(BizException.class).hasMessageContaining("无该表单定义权");
}
@Test
- @DisplayName("path 空的权限位(快应用按钮锚点那种行)不给中心管理权——中心守卫只认页面路由行")
- void blankPathAnchorIsNotAModuleRow() {
- FormAdminGuard guard = new FormAdminGuard(user -> {
- BoaModule anchor = new BoaModule();
- anchor.setCustomId("303"); // 快应用面的"导入"锚点形态:path 空 + 数字 customId
- anchor.setChildren(new ArrayList());
- List rows = new ArrayList<>();
- rows.add(anchor);
- return rows;
- });
- assertThatThrownBy(() -> guard.requireAdmin(schoolUser(7), null, FormAdminGuard.MODULE_DATA_MANAGE))
- .hasMessageContaining("无该模块权限");
- assertThat(guard.canManage(schoolUser(7))).isFalse();
+ @DisplayName("定义域拒绝面:他校表单按\"表单不存在\"回答(存在性就是探测面),非创建人非管理员按 FORBIDDEN")
+ void definitionRejectsForeignFormAsMissing() {
+ BoaUser leader = user(7);
+ assertThatThrownBy(() -> guard.requireDefinition(leader, SCH, form(OTHER_SCH, 12345)))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
+ assertThat(e.getMessage()).contains("表单不存在");
+ });
+ assertThatThrownBy(() -> guard.requireDefinition(user(1), SCH, form(SCH, 12345)))
+ .isInstanceOfSatisfying(BizException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN));
+ }
+
+ // ==================== C4② 数据域 ====================
+
+ @Test
+ @DisplayName("C4②:数据面入口=该表单 ALL 档(form_admin 命中即可,不需要任何模块行)")
+ void dataEntrypointNeedsAllTier() {
+ BoaUser teacher = user(1);
+ Form own = form(SCH, 12345);
+ assertThatThrownBy(() -> guard.requireData(teacher, SCH, own))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("无该表单数据权").contains("ALL 档");
+ });
+
+ admins.grantFormAdmin(FORM_UUID, teacher.getId());
+ assertThat(guard.requireData(teacher, SCH, own)).isEqualTo(SCH);
}
@Test
- @DisplayName("canManage(代填分支):数据管理或定义管理任一即有管理权;只有应用设置没有;未授权没有")
- void canManageFollowsModuleSet() {
- assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE).canManage(schoolUser(1))).isTrue();
- assertThat(guardWith(FormAdminGuard.MODULE_FORM_DESIGN).canManage(schoolUser(1))).isTrue();
- assertThat(guardWith(FormAdminGuard.MODULE_APP_SETTINGS).canManage(schoolUser(7))).isFalse();
- assertThat(guardWith().canManage(schoolUser(7))).isFalse();
+ @DisplayName("C4②:校管理员在平台级表单上是 ALL 档的租户限定变体,照样算入口持有者(行级由构造器收窄)")
+ void schoolAdminEntersPlatformFormViaTenantScopedAll() {
+ assertThat(guard.requireData(user(7), SCH, form(Form.PLATFORM_SCH_ID, 12345))).isEqualTo(SCH);
}
@Test
- @DisplayName("校级管理员指定他校 schId 被拒(含把 schId 传成 0 想碰平台级行)")
- void schoolAdminCannotCrossDomain() {
- FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
- BoaUser admin = schoolUser(7);
- assertThatThrownBy(() -> guard.requireAdmin(admin, 999999L, FormAdminGuard.MODULE_DATA_MANAGE))
- .isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校");
- assertThatThrownBy(() -> guard.requireAdmin(admin, 0L, FormAdminGuard.MODULE_DATA_MANAGE))
- .hasMessageContaining("仅超管可维护");
+ @DisplayName("C4②:表单取不到与\"不是你的表单\"同口径回答 PARAM(uuid 不可枚举);他校域外表单同口径")
+ void dataEntrypointFailsClosedOnFormLookup() {
+ BoaUser leader = user(7);
+ assertThatThrownBy(() -> guard.requireData(leader, SCH, null))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.PARAM_ERROR);
+ assertThat(e.getMessage()).isEqualTo("表单不存在");
+ });
+ assertThatThrownBy(() -> guard.requireData(leader, SCH, form(OTHER_SCH, 12345)))
+ .isInstanceOf(BizException.class).hasMessageContaining("表单不存在");
}
@Test
- @DisplayName("超管可显式指定任意域(含 0=平台级),未被授权模块行也能进;无当前身份时必须显式给域")
- void superAdminBypassesModuleRows() {
- FormAdminGuard guard = guardWith();
- BoaUser root = new BoaUser();
- root.setId(1);
- root.setType(1);
- assertThat(guard.requireAdmin(root, 0L, FormAdminGuard.MODULE_APP_SETTINGS)).isEqualTo(0L);
- assertThat(guard.requireAdmin(root, SCH, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
- root.setCurrentRole(role(3, (int) SCH));
- assertThat(guard.requireAdmin(root, null, FormAdminGuard.MODULE_DATA_MANAGE)).isEqualTo(SCH);
- assertThat(guard.canManage(root)).isTrue();
+ @DisplayName("按答卷寻址的数据面入口(edit/softDelete/changeStatus/detail):反查所属表单后判 ALL 档")
+ void answerAddressedEntrypointsResolveFormFirst() {
+ String answerUuid = "a-uuid-0001";
+ stubAnswer(answerUuid, FORM_UUID);
+ BoaUser teacher = user(1);
- BoaUser rootWithoutSchool = new BoaUser();
- rootWithoutSchool.setId(1);
- rootWithoutSchool.setType(1);
- assertThatThrownBy(() -> guard.requireAdmin(rootWithoutSchool, null,
- FormAdminGuard.MODULE_DATA_MANAGE)).hasMessageContaining("超管请显式指定 schId");
+ assertThatThrownBy(() -> guard.requireDataByAnswer(teacher, SCH, answerUuid))
+ .isInstanceOf(BizException.class).hasMessageContaining("无该表单数据权");
+
+ admins.grantFormAdmin(FORM_UUID, teacher.getId());
+ assertThat(guard.requireDataByAnswer(teacher, SCH, answerUuid)).isEqualTo(SCH);
}
@Test
- @DisplayName("无会话=UNAUTHORIZED(不是 FORBIDDEN)——契约 §6 定稿映射的未登录段")
- void missingSessionIsUnauthorized() {
- FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
- assertThatThrownBy(() -> guard.requireAdmin(null, null, FormAdminGuard.MODULE_DATA_MANAGE))
- .isInstanceOf(BizException.class).hasMessageContaining("未登录");
- BoaUser anonymous = new BoaUser();
- assertThatThrownBy(() -> guard.requireAdmin(anonymous, null, FormAdminGuard.MODULE_DATA_MANAGE))
- .hasMessageContaining("未登录");
- assertThatThrownBy(() -> guard.requireAdminAny(anonymous, null, FormAdminGuard.MODULE_STATS))
- .hasMessageContaining("未登录");
- assertThatThrownBy(() -> guard.requireDataView(anonymous, null, form(SCH, "f-a")))
- .hasMessageContaining("未登录");
- assertThat(guard.canManage(null)).isFalse();
- assertThat(guard.canManage(anonymous)).isFalse();
+ @DisplayName("批量入口:跨表单的批量里任意一张不在 ALL 档内即整把拒(V14\"填表人不能改状态\"不从批量漏出)")
+ void batchEntrypointRequiresAllOnEveryForm() {
+ Form inSchool = form(SCH, 12345);
+ Answer first = answer("a-1", FORM_UUID);
+ Answer second = answer("a-2", "f-uuid-0002");
+ when(answerRepository.findBySchIdAndUuidIn(anyLong(), any()))
+ .thenReturn(Arrays.asList(first, second));
+ when(formRepository.findByUuidIn(any()))
+ .thenReturn(Arrays.asList(inSchool, form(SCH, 12345, "f-uuid-0002")));
+
+ BoaUser teacher = user(1);
+ admins.grantFormAdmin(FORM_UUID, teacher.getId());
+ List ids = Arrays.asList("a-1", "a-2");
+ assertThatThrownBy(() -> guard.requireDataByAnswers(teacher, SCH, ids))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("批量里每张表都要求 ALL 档");
+ });
+
+ // 两把钥匙都配上之后放行,且逐条范围仍由 AnswerScopeQueryBuilder 把关(V39 的租户限定变体场景)
+ admins.grantFormAdmin("f-uuid-0002", teacher.getId());
+ assertThat(guard.requireDataByAnswers(teacher, SCH, ids)).isEqualTo(SCH);
+ }
+
+ // ==================== C4③ 平台管理域 ====================
+
+ @Test
+ @DisplayName("C4③:两表/app 注册/GetSchoolAll=校管理员 ∪ 超管;form_admin 不够(它只升数据范围)")
+ void platformAdminNeedsSchoolAdminOrSuper() {
+ assertThat(guard.requirePlatformAdmin(superAdmin(), null)).isEqualTo(0L);
+ assertThat(guard.requirePlatformAdmin(user(7), null)).isEqualTo(SCH);
+ BoaUser student = user(3);
+ admins.grantSchoolAdmin(SCH, student.getId());
+ assertThat(guard.requirePlatformAdmin(student, SCH)).isEqualTo(SCH);
+
+ // 反例:只有 form_admin(单表单 ALL 档)的人不进平台管理域——两表权限不外溢到维护面
+ admins.reset();
+ BoaUser teacher = user(1);
+ admins.grantFormAdmin(FORM_UUID, teacher.getId());
+ assertThatThrownBy(() -> guard.requirePlatformAdmin(teacher, SCH))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("无平台管理权");
+ });
+ }
+
+ // ==================== 租户域推导(T4 起口径原样承继) ====================
+
+ @Test
+ @DisplayName("域推导:超管显式目标域优先(含 0=平台级)、无学校归属要求显式指定;校级传 0 或他校即拒")
+ void domainDerivationKeepsT4Rules() {
+ BoaUser root = superAdmin();
+ assertThat(guard.requireCreate(root, OTHER_SCH)).isEqualTo(OTHER_SCH);
+ assertThat(guard.requireCreate(root, 0L)).isEqualTo(0L);
+ BoaUser rootNoSchool = superAdmin();
+ rootNoSchool.setCurrentRole(null);
+ assertThatThrownBy(() -> guard.requireCreate(rootNoSchool, null))
+ .isInstanceOf(BizException.class).hasMessageContaining("超管请显式指定 schId");
+
+ assertThatThrownBy(() -> guard.requireCreate(user(7), 0L))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("平台级表单/字典仅超管可维护");
+ });
+ assertThatThrownBy(() -> guard.requireCreate(user(7), OTHER_SCH))
+ .isInstanceOf(BizException.class).hasMessageContaining("仅可管理本校表单与字典");
}
@Test
- @DisplayName("有当前身份但无学校归属(区域组/根直属)不算管理权——不给出「本校」域")
- void noSchoolBindingIsNotAdmin() {
- FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
+ @DisplayName("fail-closed:未登录 UNAUTHORIZED;有角色但无学校归属的校管理员 FORBIDDEN(给不出\"本校\"域就不能放行)")
+ void sessionAndSchoolRequired() {
+ assertThatThrownBy(() -> guard.requireCreate(null, null))
+ .isInstanceOfSatisfying(BizException.class,
+ e -> assertThat(e.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED));
+ BoaUser leaderNoSchool = user(7);
+ leaderNoSchool.setCurrentRole(null);
+ assertThatThrownBy(() -> guard.requireCreate(leaderNoSchool, null))
+ .isInstanceOfSatisfying(BizException.class, e -> {
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN);
+ assertThat(e.getMessage()).contains("无学校归属,不能确定管理域");
+ });
+ }
+
+ // ==================== 构造小工具 ====================
+
+ private void stubAnswer(String answerUuid, String formUuid) {
+ when(answerRepository.findBySchIdAndUuidIn(anyLong(), any()))
+ .thenReturn(Collections.singletonList(answer(answerUuid, formUuid)));
+ when(formRepository.findByUuidIn(any())).thenReturn(Collections.singletonList(form(SCH, 12345, formUuid)));
+ }
+
+ private static Answer answer(String uuid, String formUuid) {
+ Answer answer = new Answer();
+ answer.setUuid(uuid);
+ answer.setFormUuid(formUuid);
+ answer.setSchId(SCH);
+ return answer;
+ }
+
+ private static Form form(Long schId, Integer creatorId) {
+ return form(schId, creatorId, FORM_UUID);
+ }
+
+ private static Form form(Long schId, Integer creatorId, String uuid) {
+ Form form = new Form();
+ form.setUuid(uuid);
+ form.setSchId(schId);
+ form.setAppCode(Form.CENTER_APP_CODE);
+ form.setCode("guard_form");
+ form.setName("守卫用例表单");
+ form.setCreatorId(creatorId == null ? null : Long.valueOf(creatorId));
+ return form;
+ }
+
+ private static BoaUser user(int roleId) {
BoaUser user = new BoaUser();
- user.setId(10133);
- user.setRoleIds(Collections.singletonList(7));
- user.setCurrentRole(role(7, null));
- assertThatThrownBy(() -> guard.requireAdmin(user, null, FormAdminGuard.MODULE_DATA_MANAGE))
- .hasMessageContaining("无学校归属");
- assertThat(guard.canManage(user)).isFalse();
-
- BoaUser full = schoolUser(7);
- assertThatCode(() -> guard.requireAdmin(full, SCH, FormAdminGuard.MODULE_DATA_MANAGE))
- .doesNotThrowAnyException();
- }
-
- // ==================== V46:数据面入口判定 requireDataView(T10 方案 §5.2) ====================
-
- @Test
- @DisplayName("V46 数据面入口:本校表单只要持有数据管理或统计任一行即放行,域=本校")
- void dataViewAcceptsEitherModuleRowOnOwnForm() {
- assertThat(guardWith(FormAdminGuard.MODULE_DATA_MANAGE)
- .requireDataView(schoolUser(1), null, form(SCH, "f-own"))).isEqualTo(SCH);
- assertThat(guardWith(FormAdminGuard.MODULE_STATS)
- .requireDataView(schoolUser(1), null, form(SCH, "f-own"))).isEqualTo(SCH);
- assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_APP_SETTINGS)
- .requireDataView(schoolUser(1), null, form(SCH, "f-own")))
- .isInstanceOf(BizException.class).hasMessageContaining("需任一");
- }
-
- @Test
- @DisplayName("V46 基础表单(sch_id=0)在本校域下可读:入口不看管理员两表(守卫红线:两表不作入口凭证),"
- + "能看哪些行由 DataScopeService 判")
- void dataViewOpensBaseFormOnOwnDomain() {
- FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
- assertThat(guard.requireDataView(schoolUser(1), null, form(Form.PLATFORM_SCH_ID, "f-base")))
- .isEqualTo(SCH);
- // 但把目标域显式传成 0 仍是超管专属(平台级行的维护口径不变)
- assertThatThrownBy(() -> guard.requireDataView(schoolUser(1), 0L, form(Form.PLATFORM_SCH_ID, "f-base")))
- .isInstanceOf(BizException.class).hasMessageContaining("仅超管可维护");
- }
-
- @Test
- @DisplayName("V46 他校表单与不存在的表单同一口径回答(PARAM_ERROR 表单不存在,不给\"无权限\"这种差别信息)")
- void dataViewTreatsForeignFormAsAbsent() {
- FormAdminGuard guard = guardWith(FormAdminGuard.MODULE_DATA_MANAGE);
- assertThatThrownBy(() -> guard.requireDataView(schoolUser(7), null, form(OTHER_SCH, "f-other")))
- .isInstanceOf(BizException.class)
- .hasMessageContaining("表单不存在");
- assertThatThrownBy(() -> guard.requireDataView(schoolUser(7), null, null))
- .hasMessageContaining("表单不存在");
- }
-
- @Test
- @DisplayName("V46 超管的数据面入口域语义与 requireAdmin 一致:显式目标域优先(含 0=平台级),不给更窄默认")
- void dataViewKeepsSuperAdminDomainSemantics() {
- FormAdminGuard guard = guardWith();
- BoaUser root = new BoaUser();
- root.setId(1);
- root.setType(1);
- assertThat(guard.requireDataView(root, SCH, form(Form.PLATFORM_SCH_ID, "f-base"))).isEqualTo(SCH);
- assertThat(guard.requireDataView(root, 0L, form(Form.PLATFORM_SCH_ID, "f-base"))).isEqualTo(0L);
- }
-
- @Test
- @DisplayName("V46 数据面入口:有当前身份但无学校归属=拒(给不出查询域就没有范围可解析)")
- void dataViewNeedsSchoolBinding() {
- BoaUser user = new BoaUser();
- user.setId(10134);
- user.setRoleIds(Collections.singletonList(1));
- user.setCurrentRole(role(1, null));
- assertThatThrownBy(() -> guardWith(FormAdminGuard.MODULE_DATA_MANAGE)
- .requireDataView(user, null, form(SCH, "f-own")))
- .isInstanceOf(BizException.class).hasMessageContaining("无学校归属");
- }
-
- /** 授权集合直给的守卫(模块行 path 变参;path 空行不在此列,见权限位用例) */
- private static FormAdminGuard guardWith(String... paths) {
- return new FormAdminGuard(user -> {
- List rows = new ArrayList<>();
- for (String path : paths) {
- BoaModule module = new BoaModule();
- module.setPath(path);
- module.setChildren(new ArrayList());
- rows.add(module);
- }
- return rows;
- });
- }
-
- private BoaUser schoolUser(int roleId) {
- BoaUser user = new BoaUser();
- user.setId(10133);
- user.setType(2);
- user.setRoleIds(Arrays.asList(roleId));
+ user.setId(30000 + roleId);
+ user.setType(0);
+ user.setRoleIds(Collections.singletonList(roleId));
user.setCurrentRole(role(roleId, (int) SCH));
return user;
}
- private BoaRoleInfo role(Integer roleId, Integer schId) {
+ private static BoaUser superAdmin() {
+ BoaUser user = user(9);
+ user.setType(1);
+ user.setCurrentRole(role(9, 0));
+ return user;
+ }
+
+ private static BoaRoleInfo role(Integer roleId, Integer schId) {
BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId);
info.setSchId(schId);
return info;
}
-
- private static Form form(long schId, String uuid) {
- Form form = new Form();
- form.setSchId(schId);
- form.setUuid(uuid);
- return form;
- }
}
diff --git a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java b/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java
deleted file mode 100644
index aac7f1a..0000000
--- a/src/test/java/com/yunhai/formcenter/service/InMemoryModuleTreeSource.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.platform.utils.model.BoaModule;
-import com.yunhai.platform.utils.model.BoaUser;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * {@link ModuleTreeSource} 的测试桩(T4:本地 IT 不启动 UserBase/CAS,P21 的管理权口径注入这里取证,
- * 真实链路 T9 联调验)。
- *
- * 建模的是 UserBase 侧的页面路由行(手册 §2.2:{@code path} 逐字对上 front 路由)。缺省不给任何
- * 模块——与生产侧 fail-closed 同向(无授权=无管理权、范围回落到仅本人提交),需要权限的用例显式授权。
- *
- * T10 起本桩只剩 path 一种形态:{@code customId} 权限锚点协议退役,数据范围升级改由管理员两表提供
- * ({@link InMemoryAdminScopeSource})。
- */
-public class InMemoryModuleTreeSource implements ModuleTreeSource {
-
- private final Map> byUser = new ConcurrentHashMap<>();
-
- /** 授权页面路由模块行(path 逐字用手册 §2.2 的那几条) */
- public void grantPaths(Integer userId, String... paths) {
- List rows = rows(userId);
- for (String path : paths) {
- rows.add(row(path));
- }
- }
-
- /** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */
- public void clear(Integer userId) {
- byUser.remove(userId);
- }
-
- /** 每条用例前清空全部授权(脚手架 @BeforeEach 调它,防用例间串权限) */
- public void reset() {
- byUser.clear();
- }
-
- @Override
- public List authorizedModules(BoaUser user) {
- if (user == null || user.getId() == null) {
- return Collections.emptyList();
- }
- // 桩返回扁平列表即可:FormModuleKeys 的递归遍历对 children=null 与树形都同样处理
- return Collections.unmodifiableList(
- new ArrayList<>(byUser.getOrDefault(user.getId(), Collections.emptyList())));
- }
-
- private List rows(Integer userId) {
- return byUser.computeIfAbsent(userId, key -> Collections.synchronizedList(new ArrayList<>()));
- }
-
- private static BoaModule row(String path) {
- BoaModule module = new BoaModule();
- module.setPath(path);
- module.setName("模块 " + path);
- module.setChildren(new ArrayList());
- return module;
- }
-
- /** 便于用例一行授权"数据管理 + 定义管理"(=手册 §2.2 的中心管理员常见配法) */
- public void grantManageDefaults(Integer userId) {
- grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN);
- }
-}
diff --git a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
index 15adcdf..68227a9 100644
--- a/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
+++ b/src/test/java/com/yunhai/formcenter/service/MyAnswerIT.java
@@ -108,8 +108,8 @@ class MyAnswerIT extends AnswerTestSupport {
assertThat(mapped).containsExactlyInAnyOrder(
"GET /list", "GET /submit-list", "GET /about-list", "GET /detail",
"POST /delete", "POST /edit",
- // P95 W3 新增的填报入口清单:同样只吃会话身份(appCode 是应用维度筛项,不是身份)
- "GET /fillable");
+ // 契约 v2 §2.4 的三支路自解析端点:同样只吃会话身份(appCode 是应用维度筛项,不是身份)
+ "GET /fillable", "GET /managed", "GET /capabilities");
// V14 的结构半边:本面没有任何改状态端点
assertThat(mapped).noneMatch(p -> p.contains("status"));
assertThat(fieldNames(FormMyAnswerController.DeleteRequest.class)).containsExactly("answerId");
@@ -299,13 +299,13 @@ class MyAnswerIT extends AnswerTestSupport {
selfFill.setSubjectId(studentId);
String answerId = answerService.submit(student, selfFill).getAnswerId();
- moduleTree.clear(studentId);
assertThat(idsOf(controller.submitList(student))).containsExactly(answerId);
assertThat(idsOf(controller.aboutList(student))).containsExactly(answerId);
assertThat(controller.detail(student, answerId).getAnswerId()).isEqualTo(answerId);
- // 对照:数据管理面要求 /admin/data 模块行,同一个人过去就是 FORBIDDEN(两套口径不混用)
- assertThatThrownBy(() -> adminGuard.requireAdmin(student, SCH, FormAdminGuard.MODULE_DATA_MANAGE))
- .isInstanceOf(BizException.class);
+ // 对照:数据管理面入口按该表单 ALL 档把关(C4②),同一个人以填报人身份过去仍是 FORBIDDEN
+ // ——"我的面"能看能改自己的行,"管理面"要 ALL 档,两套口径不混用(V14/V17 同源断言)
+ assertThatThrownBy(() -> adminGuard.requireDataByAnswer(student, SCH, answerId))
+ .isInstanceOf(BizException.class).hasMessageContaining("无该表单数据权");
}
@Test
diff --git a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
index 4457a3e..8d85c14 100644
--- a/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
+++ b/src/test/java/com/yunhai/formcenter/service/Perf200kProbe.java
@@ -5,7 +5,6 @@ import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.model.ReadModels;
-import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName;
@@ -57,7 +56,7 @@ import java.util.stream.Collectors;
* searchable 四字段各写索引行(≈78 万),每 50 行置 1 行软删(让谓词真实参与)。
*
* 口径边界(报告要点名):测的是 service+SQL 层延迟,不含 HTTP/CAS/JSON 序列化
- * 的用户面开销,也不含 UserBase 签名调用的网络耗时(模块树被桩顶掉;P44 单独量化每请求取树次数)。
+ * 的用户面开销,也不含 UserBase 签名调用的网络耗时(契约 v2 起中心不再取模块树,P44 度量对象已消失)。
* 本机 MySQL 8.0.45 与 160 容器网 mysql8 同大版本。
*/
@SpringBootTest(properties = {
@@ -66,7 +65,7 @@ import java.util.stream.Collectors;
+ "&allowPublicKeyRetrieval=true",
"spring.jpa.hibernate.ddl-auto=update"
})
-@DisplayName("T9 压测探针(V10 阈值 + P26 重号率 + P35 索引选择性 + P37 缓存语义 + P44 取树次数)")
+@DisplayName("T9 压测探针(V10 阈值 + P26 重号率 + P35 索引选择性 + P37 缓存语义)")
class Perf200kProbe {
private static final long SCH = 990600L;
@@ -109,32 +108,12 @@ class Perf200kProbe {
private AnswerStatsService statsService;
@Autowired
private AnswerExportService exportService;
- @Autowired
- private CountingModuleTreeSource moduleTree;
-
private final List report = new ArrayList<>();
private final List sqlLog = new ArrayList<>();
private final AtomicInteger userSeq = new AtomicInteger(910000);
- /** 模块树桩的计数版(P44):桩顶掉签名调用的同时把调用次数留下来,否则 P44 只能靠读代码猜 */
- static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
- final AtomicInteger calls = new AtomicInteger();
-
- @Override
- public List authorizedModules(BoaUser user) {
- calls.incrementAndGet();
- return super.authorizedModules(user);
- }
- }
-
@TestConfiguration
static class PerfGatewayConfiguration {
- @Bean
- @Primary
- CountingModuleTreeSource countingModuleTreeSource() {
- return new CountingModuleTreeSource();
- }
-
@Bean
@Primary
InMemorySubjectDirectory perfSubjectDirectory() {
@@ -407,7 +386,7 @@ class Perf200kProbe {
.filter(s -> s.toLowerCase().startsWith("select")).collect(Collectors.toList());
}
- // ==================== P26 / P37 / P44 ====================
+ // ==================== P26 / P37 ====================
private void p37CacheSemantics(BoaUser manager, String uuid) {
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
@@ -443,25 +422,11 @@ class Perf200kProbe {
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
stats.setFormUuid(uuid);
- // 详情用的 uuid 先取好——否则取首屏那一次 list 会被算进详情的计数里
- String detailUuid = cursorOfFirstRow(manager, list);
- log("P44 每请求取树次数:列表=" + probe(() -> readService.list(manager, SCH, list))
- + " 统计=" + probe(() -> statsService.stats(manager, SCH, stats))
- + " 导出=" + probe(() -> exportService.export(manager, SCH, stats, new ByteArrayOutputStream()))
- + " 详情=" + probe(() -> readService.detail(manager, SCH, detailUuid)));
- log("P44 口径:阈值内(V10 p95 达标)按一期现状保持,不改成控制器内一次取树两处复用;"
- + "生产侧每次取树=一次 UserBase 签名调用,网络耗时不在本探针口径内(类注释已记档)");
- }
-
- private String cursorOfFirstRow(BoaUser manager, ReadModels.ListRequest list) {
- return readService.list(manager, SCH, list).getRows().get(0).getAnswerId();
- }
-
- /** 跑一次并返回这段时间里模块树被取了几次 */
- private int probe(Runnable body) {
- int before = moduleTree.calls.get();
- body.run();
- return moduleTree.calls.get() - before;
+ // P44(每请求取 UserBase 模块树的次数)随契约 v2 模块行整条退役而失去度量对象:
+ // 管理面守卫不再取树(§19 作废-10),四条读路径的每请求外部调用只剩 SubjectDirectory 一类,
+ // 因此这里只保留 V10 的延迟读数,不再打取树计数。
+ readService.list(manager, SCH, list);
+ statsService.stats(manager, SCH, stats);
}
/** P26:answer_no 当日序号 count+1 的重号率——并发真实提交才量得出来(不限次 + 报名号的长尾组合) */
@@ -513,7 +478,6 @@ class Perf200kProbe {
user.setType(2);
user.setRoleIds(Collections.singletonList(7));
user.setCurrentRole(role(7));
- moduleTree.grantManageDefaults(900001);
return user;
}
diff --git a/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java b/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java
deleted file mode 100644
index 70fc74e..0000000
--- a/src/test/java/com/yunhai/formcenter/service/PerfTreeCallsProbe.java
+++ /dev/null
@@ -1,222 +0,0 @@
-package com.yunhai.formcenter.service;
-
-import com.yunhai.formcenter.controller.api.FormAnswerReadController;
-import com.yunhai.formcenter.model.ReadModels;
-import com.yunhai.platform.utils.model.BoaModule;
-import com.yunhai.platform.utils.model.BoaRoleInfo;
-import com.yunhai.platform.utils.model.BoaUser;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Primary;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.mock.web.MockHttpServletResponse;
-
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-
-/**
- * T9 压测补探针(控制器形状):{@code Perf200kProbe} 直调服务层,量到的"每请求取树次数"少了一次
- * 控制器侧 {@code FormAdminGuard.requireAdmin} 的取树——那正是 P44 问的东西("每请求 1~2 次"里的 2 从哪来)。
- * 本探针打 {@link FormAnswerReadController} 的端点方法(会话用户 + 请求体,与线上调用形状一致),
- * 把 P44 的数字钉成请求级,并顺带给出 V10 三项在含守卫形状下的延迟。
- *
- * 数据复用 {@code bsch_formcenter_perf}(由 {@code Perf200kProbe} 造好,20 万答卷 / 78 万索引行)。
- * 命名不带 Test/IT 后缀 → 默认门禁不收集;手点:{@code mvn -B -o test -Dtest=PerfTreeCallsProbe}。
- */
-@SpringBootTest(properties = {
- "spring.datasource.url=jdbc:mysql://localhost:3306/bsch_formcenter_perf?createDatabaseIfNotExist=true"
- + "&useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"
- + "&allowPublicKeyRetrieval=true",
- "spring.jpa.hibernate.ddl-auto=none"
-})
-@DisplayName("T9 压测补探针:请求级取树次数(P44)+ 含守卫形状下的 V10 延迟")
-class PerfTreeCallsProbe {
-
- private static final long SCH = 990600L;
- private static final int MANAGER_ID = 900001;
-
- @Autowired
- private JdbcTemplate jdbc;
- @Autowired
- private FormDefService defService;
- @Autowired
- private FormAnswerReadController controller;
- @Autowired
- private CountingModuleTreeSource moduleTree;
-
- static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
- final AtomicInteger calls = new AtomicInteger();
-
- @Override
- public List authorizedModules(BoaUser user) {
- calls.incrementAndGet();
- return super.authorizedModules(user);
- }
- }
-
- @TestConfiguration
- static class ProbeGatewayConfiguration {
- @Bean
- @Primary
- CountingModuleTreeSource countingModuleTreeSource() {
- return new CountingModuleTreeSource();
- }
-
- @Bean
- @Primary
- InMemorySubjectDirectory probeSubjectDirectory() {
- return new InMemorySubjectDirectory();
- }
- }
-
- private final List report = new ArrayList<>();
-
- @Test
- @DisplayName("P44:列表/详情/统计/筛选候选/导出 各自一次请求取几棵树;同时给含守卫的请求级延迟")
- void requestShape() throws Exception {
- BoaUser manager = manager();
- String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f01'",
- String.class);
- String sampleAnswer = jdbc.queryForObject("select uuid from answer where sch_id=" + SCH
- + " and code='perf_f01' and is_deleted=0 order by id desc limit 1", String.class);
-
- ReadModels.ListRequest list = new ReadModels.ListRequest();
- list.setFormUuid(uuid);
- list.setSize(20);
- ReadModels.ListRequest filtered = new ReadModels.ListRequest();
- filtered.setFormUuid(uuid);
- filtered.setSize(20);
- filtered.setFilters(Arrays.asList(eq("grade", "g2"), range("score", "30", "80")));
- ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
- stats.setFormUuid(uuid);
- stats.setGroupByField("grade");
-
- log("P44 请求级取树次数(控制器形状,含 requireAdmin):列表="
- + probe(() -> controller.list(manager, null, list))
- + " 详情=" + probe(() -> controller.detail(manager, null, sampleAnswer))
- + " 统计=" + probe(() -> controller.stats(manager, null, stats))
- + " 筛选候选=" + probe(() -> controller.filterFields(manager, null, uuid))
- + " 导出=" + probe(() -> exportThroughController(manager, stats)));
-
- // 请求级延迟(比服务层多一次守卫取树;桩里取树是内存操作,真 UserBase 是签名 HTTP 调用)
- sample("V10 请求级 列表 首屏", 40, () -> controller.list(manager, null, list));
- sample("V10 请求级 三条件筛选", 40, () -> controller.list(manager, null, filtered));
- sample("V10 请求级 统计(缓存命中态)", 20, () -> controller.stats(manager, null, stats));
- long start = System.nanoTime();
- int bytes = exportThroughController(manager, stats);
- log("V10 请求级 导出(2 万级,写满 MockHttpServletResponse 缓冲区):字节=" + bytes
- + " 耗时(ms)=" + (System.nanoTime() - start) / 1_000_000L + " 阈值=20000");
-
- p36PublishTiming();
- Files.write(Paths.get(System.getProperty("user.dir"), "..", "local-debug", "perf-20261015",
- "perf-request-shape.txt"), report, StandardCharsets.UTF_8);
- }
-
- private int exportThroughController(BoaUser manager, ReadModels.QueryRequest body) {
- try {
- MockHttpServletResponse response = new MockHttpServletResponse();
- controller.export(manager, null, body, response);
- return response.getContentAsByteArray().length;
- } catch (Exception e) {
- throw new IllegalStateException("导出端点调用失败:" + e.getMessage(), e);
- }
- }
-
- /**
- * P36 的实测项:升版发布时同步补建历史索引行({@code backfillAfterPublish})在 2 万行表单上到底多久。
- * P36 记档的估计是"2 万级秒级",而 T9 的处置线写的是"若实测不可接受,改点在 FormDefService.publish 一处"——
- * 所以要量的是 publish 这一步本身,不是整段流程。
- */
- private void p36PublishTiming() {
- String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f02'",
- String.class);
- Integer current = jdbc.queryForObject("select current_version from form where uuid='" + uuid + "'",
- Integer.class);
- String schema = jdbc.queryForObject("select schema_json from form_version where form_id="
- + "(select id from form where uuid='" + uuid + "') and version=" + current, String.class);
- com.yunhai.formcenter.model.FormModels.FormSaveRequest draft =
- new com.yunhai.formcenter.model.FormModels.FormSaveRequest();
- draft.setUuid(uuid);
- draft.setSchemaJson(schema.replace("{\"key\":\"remark\"",
- "{\"key\":\"p36_extra\",\"label\":\"P36 计时字段\",\"type\":\"text\",\"searchable\":true},"
- + "{\"key\":\"remark\""));
- long saveStart = System.nanoTime();
- defService.save(SCH, draft);
- long saveMs = (System.nanoTime() - saveStart) / 1_000_000L;
- long publishStart = System.nanoTime();
- defService.publish(SCH, uuid, "P36 升版补建索引计时(新增 searchable 字段)");
- long publishMs = (System.nanoTime() - publishStart) / 1_000_000L;
- Integer indexed = jdbc.queryForObject("select count(*) from answer_index where sch_id=" + SCH
- + " and code='perf_f02' and field_key='p36_extra'", Integer.class);
- long live = jdbc.queryForObject("select count(*) from answer where sch_id=" + SCH
- + " and code='perf_f02' and is_deleted=0", Long.class);
- log("P36 升版补建实测:草稿保存=" + saveMs + "ms;**发布=" + publishMs + "ms**(含同步补建索引);"
- + "本表未删答卷=" + live + " 行,新字段索引行补出=" + indexed + " 行"
- + "(P36 记档的\"2 万级秒级\"与实测的差值见 T9 报告 §4.5)");
- }
-
- private int probe(Runnable body) {
- int before = moduleTree.calls.get();
- body.run();
- return moduleTree.calls.get() - before;
- }
-
- private void sample(String label, int times, Runnable body) {
- long coldStart = System.nanoTime();
- body.run();
- long cold = (System.nanoTime() - coldStart) / 1_000_000L;
- List ms = new ArrayList<>();
- for (int i = 0; i < times; i++) {
- long start = System.nanoTime();
- body.run();
- ms.add((System.nanoTime() - start) / 1_000_000L);
- }
- Collections.sort(ms);
- log(label + " | n=" + ms.size() + " 冷启=" + cold + "ms p50=" + ms.get(ms.size() / 2) + "ms p95="
- + ms.get(Math.min(ms.size() - 1, (int) Math.ceil(ms.size() * 0.95) - 1)) + "ms max="
- + ms.get(ms.size() - 1) + "ms");
- }
-
- private BoaUser manager() {
- BoaUser user = new BoaUser();
- user.setId(MANAGER_ID);
- user.setType(2);
- user.setRoleIds(Collections.singletonList(7));
- BoaRoleInfo current = new BoaRoleInfo();
- current.setRoleId(7);
- current.setSchId((int) SCH);
- user.setCurrentRole(current);
- moduleTree.grantManageDefaults(MANAGER_ID);
- return user;
- }
-
- private static ReadModels.FilterItem eq(String key, String value) {
- ReadModels.FilterItem item = new ReadModels.FilterItem();
- item.setKey(key);
- item.setValue(value);
- return item;
- }
-
- private static ReadModels.FilterItem range(String key, String from, String to) {
- ReadModels.FilterItem item = new ReadModels.FilterItem();
- item.setKey(key);
- item.setFrom(from);
- item.setTo(to);
- return item;
- }
-
- private void log(String line) {
- report.add(line);
- System.out.println("[PERF] " + line);
- }
-}