t14:模块行退役 + 菜单自解析——守卫换 C4 三段角色门,新增 managed/capabilities 端点
- FormAdminGuard 整体重写:requireCreate(超管∪校管理员,G1)/requireDefinition(+该表单创建人)
/requireData(该表单 ALL 档)/requirePlatformAdmin(校管理员∪超管);答卷寻址的入口
先由 uuid 反查所属表单再判同一道门(requireDataByAnswer/requireDataByAnswers);
租户域推导(超管可指定含 0、校级只能本校、传 0 即拒)一字未改
- 摘除 ModuleTreeSource/PlatformModuleTreeSource/FormModuleKeys 与 GetUserMod 端点
(§19 作废-10:中心不再消费授权树,B14 缺口与 H4 依赖随之消失)
- 新增 GET /api/form/my/managed(C3:创建人 ∪ form_admin,role 标来源)与
GET /api/form/my/capabilities({schoolAdmin},"系统设置"入口唯一数据源);
managed 行额外带 formUuid/status(P120:只给 C3 的"fillable 行+role"点不进设计器/数据页)
- 两表/app 注册/GetSchoolAll 守卫切到 requirePlatformAdmin;字典同样吃这一道门
- h2_userbase_app_and_modules.sql 文件头加"整份作废、不再在任何环境执行"注记(正文保留供追溯)
- 测试:FormAdminGuardTest 按三段门重写(含批量入口跨表逐张判 ALL、fail-closed 与域推导);
FormBaseControllerTest 改成"GetUserMod 端点反射级不存在"断言;AdminDomainIT/MyAnswerIT/
AnswerAdminIT/AnswerReadIT 的模块行断言换角色门载体;PerfTreeCallsProbe(P44 取树次数)
随度量对象消失而删除
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
-- ============================================================================
|
||||
-- ⛔ 契约 v2 终态(T14,2026-10-21 用户裁决落地):**本脚本整份作废,不再在任何环境执行**。
|
||||
-- 依据=《表单中心契约v2-权限简化与开放接入.md》§2.4(D5:UserBase 模块行整条退役)
|
||||
-- 与 §19 作废-10:中心五行 customId 200~204、300 段快应用行、组授权全部不再登记;
|
||||
-- 后端 `GetUserMod` 端点与 `ModuleTreeSource` 已删除,左导航改由三支路自解析
|
||||
-- (`/api/form/my/fillable`、`/my/managed`、`/my/capabilities`)。
|
||||
-- H2 登记事项永久关闭(含"超管加 /f/honor_student 模块行"这条遗留项)。
|
||||
-- 下方原文**按用户要求整份保留供追溯**(不作删除),其中的"执行/复核"步骤一律不再执行;
|
||||
-- UserBase 侧存量行(200~204 与 300 段)的摘除属 P-C 确认后的用户侧动作,见《执行手册》§5 与本批报告。
|
||||
-- ============================================================================
|
||||
--
|
||||
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
|
||||
-- 编制:Qoder(T9 联调轮)|日期:2026-10-15|改版:T10 中心段 6→5 行(手册 §2.2 v1.5,锚点行退役)
|
||||
-- 改版:**P95(2026-10-16)快应用段 5→4 行——`/f/{code}` 填报行及其组授权整条撤销**
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.yunhai.formcenter.common;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 授权模块树的权限键扁平化(方案 §6.1 第②层)。
|
||||
*
|
||||
* <p><b>只收 {@code path} 非空的页面路由行</b>:管理权按它判(手册 §2.2 表)。{@code path} 空的行是
|
||||
* 纯权限位,不进导航——中心应用 v1.5 起不再有这种行,该形态只存在于快应用面的按钮锚点
|
||||
* (导入/导出,由前端 {@code modulePerms} 直读原始树 customId 消费,后端不参与)。</p>
|
||||
*
|
||||
* <p>数据范围升级位(UserBase 侧的 customId 锚点协议)已在 T10 退役,载体换成管理员两表
|
||||
* ({@code AdminScopeSource},管理员体系方案 §5.1/§7),所以本类不再提供 customId 侧的取键方法。</p>
|
||||
*
|
||||
* <p>纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。</p>
|
||||
*/
|
||||
public final class FormModuleKeys {
|
||||
|
||||
private FormModuleKeys() {
|
||||
}
|
||||
|
||||
/** 授权页面路由集合(path 空的权限位不在内) */
|
||||
public static Set<String> paths(List<BoaModule> tree) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collect(tree, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归展平。取不出 path 的节点自然被跳过,子节点照常下钻(页面行可以挂在分组层下面)。
|
||||
*/
|
||||
private static void collect(List<BoaModule> nodes, Set<String> out) {
|
||||
if (nodes == null) {
|
||||
return;
|
||||
}
|
||||
for (BoaModule node : nodes) {
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
String path = trimToNull(node.getPath());
|
||||
if (path != null) {
|
||||
out.add(path);
|
||||
}
|
||||
collect(node.getChildren(), out);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
return value == null || value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员两表的维护端点(T10 方案 §5.3):校级表单管理员三端点摊 {@code /admin/app}(应用设置行),
|
||||
* 单表单管理员三端点摊 {@code /admin/forms}(表单设计行)。
|
||||
* 管理员两表的维护端点(T10 方案 §5.3;契约 v2 C4③ 换守卫)。
|
||||
*
|
||||
* <p><b>这两组端点属于维护面,不是数据面</b>:谁能进角色设置页由 UserBase 模块行决定(守卫红线——
|
||||
* 管理员表本身不作入口凭证),加进去的人只升级数据范围({@code DataScopeService})。
|
||||
* <p><b>这两组端点属于维护面,不是数据面</b>:谁能进角色设置页由 {@code FormAccessPolicy.isSchoolAdmin}
|
||||
* 决定(校管理员 ∪ 超管),加进去的人只升级数据范围({@code DataScopeService})——
|
||||
* 原口径"两表不作入口凭证"仍然成立,只是入口判据从 UserBase 模块行换成了本库角色。
|
||||
* 目标域一律走 {@code FormAdminGuard} 的会话域校验:校级只本校、超管可显式指定(含代查任意校),
|
||||
* 名单里的人再逐个复核归属({@code AdminDomainService})。</p>
|
||||
*
|
||||
@@ -46,14 +46,13 @@ public class AdminDomainController {
|
||||
public List<AdminModels.AdminView> listSchoolAdmins(BoaUser user,
|
||||
@RequestParam(required = false) Long schId) {
|
||||
return adminService.listSchoolAdmins(
|
||||
adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
|
||||
adminGuard.requirePlatformAdmin(user, schId));
|
||||
}
|
||||
|
||||
@PostMapping("/school-admins")
|
||||
public List<AdminModels.AdminView> addSchoolAdmins(BoaUser user,
|
||||
@RequestBody AdminModels.AdminAddRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, body == null ? null : body.getSchId(),
|
||||
FormAdminGuard.MODULE_APP_SETTINGS);
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, body == null ? null : body.getSchId());
|
||||
return adminService.addSchoolAdmins(domain, body == null ? null : body.getUsers());
|
||||
}
|
||||
|
||||
@@ -61,7 +60,7 @@ public class AdminDomainController {
|
||||
public List<AdminModels.AdminView> removeSchoolAdmin(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@PathVariable("id") Long id) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS);
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, schId);
|
||||
return adminService.removeSchoolAdmin(domain, id);
|
||||
}
|
||||
|
||||
@@ -72,7 +71,7 @@ public class AdminDomainController {
|
||||
public List<AdminModels.AdminView> listFormAdmins(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@PathVariable("formUuid") String formUuid) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, schId);
|
||||
return adminService.listFormAdmins(requireForm(domain, formUuid));
|
||||
}
|
||||
|
||||
@@ -81,7 +80,7 @@ public class AdminDomainController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@PathVariable("formUuid") String formUuid,
|
||||
@RequestBody AdminModels.AdminAddRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, schId);
|
||||
Form form = requireForm(domain, formUuid);
|
||||
return adminService.addFormAdmins(form, domain, body == null ? null : body.getUsers());
|
||||
}
|
||||
@@ -91,7 +90,7 @@ public class AdminDomainController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@PathVariable("formUuid") String formUuid,
|
||||
@PathVariable("id") Long id) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
Long domain = adminGuard.requirePlatformAdmin(user, schId);
|
||||
Form form = requireForm(domain, formUuid);
|
||||
return adminService.removeFormAdmin(form, id);
|
||||
}
|
||||
|
||||
@@ -45,21 +45,21 @@ public class FormAdminController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam(required = false) String appCode,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
return defService.list(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), appCode, status);
|
||||
return defService.list(adminGuard.requireCreate(user, schId), appCode, status);
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
public FormModels.FormView get(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.get(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
return defService.get(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
|
||||
}
|
||||
|
||||
@GetMapping("/versions")
|
||||
public List<FormModels.VersionView> versions(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.versions(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
return defService.versions(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
|
||||
}
|
||||
|
||||
/** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */
|
||||
@@ -68,7 +68,7 @@ public class FormAdminController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid,
|
||||
@RequestParam("version") Integer version) {
|
||||
return defService.version(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, version);
|
||||
return defService.version(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid, version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +80,7 @@ public class FormAdminController {
|
||||
public List<String> filterFields(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
|
||||
return current == null ? java.util.Collections.emptyList()
|
||||
: new java.util.ArrayList<>(FormSchemaValidator.filterableFields(current.getSchemaJson()).keySet());
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView create(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormCreateRequest body) {
|
||||
return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN),
|
||||
return defService.create(adminGuard.requireCreate(user, schId),
|
||||
body, creatorId(user));
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView save(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormSaveRequest body) {
|
||||
return defService.save(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
return defService.save(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body);
|
||||
}
|
||||
|
||||
/** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */
|
||||
@@ -113,7 +113,7 @@ public class FormAdminController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid,
|
||||
@RequestParam(value = "changeLog", required = false) String changeLog) {
|
||||
return defService.publish(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, changeLog);
|
||||
return defService.publish(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid, changeLog);
|
||||
}
|
||||
|
||||
/** 停用:只断填报入口,数据可读可导(方案 §5.4) */
|
||||
@@ -121,7 +121,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView disable(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.disable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
return defService.disable(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
|
||||
}
|
||||
|
||||
/** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */
|
||||
@@ -129,7 +129,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView enable(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
return defService.enable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
return defService.enable(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
|
||||
}
|
||||
|
||||
/** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */
|
||||
@@ -137,7 +137,7 @@ public class FormAdminController {
|
||||
public void softDelete(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("uuid") String uuid) {
|
||||
defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
|
||||
defService.softDelete(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(uuid)), uuid);
|
||||
}
|
||||
|
||||
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行;创建人=点副本的人 */
|
||||
@@ -145,7 +145,8 @@ public class FormAdminController {
|
||||
public FormModels.FormView copy(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormCopyRequest body) {
|
||||
return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN),
|
||||
return defService.copy(adminGuard.requireDefinition(user, schId,
|
||||
defService.findFormForGuard(body.getUuid())),
|
||||
body, creatorId(user));
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView config(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.FormConfigRequest body) {
|
||||
return defService.updateConfig(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
return defService.updateConfig(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body);
|
||||
}
|
||||
|
||||
/** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */
|
||||
@@ -162,7 +163,7 @@ public class FormAdminController {
|
||||
public FormModels.FormView statusDefs(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody FormModels.StatusDefsRequest body) {
|
||||
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
return defService.updateStatusDefs(adminGuard.requireDefinition(user, schId, defService.findFormForGuard(body.getUuid())), body);
|
||||
}
|
||||
|
||||
// ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ====================
|
||||
@@ -170,36 +171,36 @@ public class FormAdminController {
|
||||
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
|
||||
@GetMapping("/dict/list")
|
||||
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
|
||||
return dictService.listOwn(adminGuard.requirePlatformAdmin(user, schId));
|
||||
}
|
||||
|
||||
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
|
||||
@GetMapping("/dict/selectable")
|
||||
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
|
||||
return dictService.listSelectable(adminGuard.requirePlatformAdmin(user, schId));
|
||||
}
|
||||
|
||||
@PostMapping("/dict/save")
|
||||
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
|
||||
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode(), body.getName());
|
||||
return dictService.saveDict(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getCode(), body.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/dict/delete")
|
||||
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
|
||||
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode());
|
||||
dictService.deleteDict(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getCode());
|
||||
}
|
||||
|
||||
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
|
||||
@PostMapping("/dict/item/save")
|
||||
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
|
||||
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS),
|
||||
return dictService.saveItem(adminGuard.requirePlatformAdmin(user, body.getSchId()),
|
||||
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
|
||||
}
|
||||
|
||||
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
|
||||
@PostMapping("/dict/item/delete")
|
||||
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
|
||||
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getDictCode(), body.getValue());
|
||||
dictService.deleteItem(adminGuard.requirePlatformAdmin(user, body.getSchId()), body.getDictCode(), body.getValue());
|
||||
}
|
||||
|
||||
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
|
||||
|
||||
@@ -17,11 +17,11 @@ import java.util.List;
|
||||
* 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。
|
||||
*
|
||||
* <p>这里<b>没有</b>编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用"
|
||||
* 的实现形态就是<b>不存在这些端点</b>,加上数据管理模块行守卫给出的 403)。改状态同理:
|
||||
* 的实现形态就是<b>不存在这些端点</b>,加上数据面入口的 ALL 档守卫给出的 403)。改状态同理:
|
||||
* 填表人在接口层即被拒(V14)。</p>
|
||||
*
|
||||
* <p><b>两层权限</b>(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data},
|
||||
* 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次<b>行级数据范围</b>
|
||||
* <p><b>两层权限</b>(T4 / P30 收紧点;契约 v2 C4②):控制器按<b>该表单 ALL 档</b>把关
|
||||
* (答卷寻址的端点先反查所属表单再判),决定"能不能用数据管理接口";服务内每条答卷再过一次<b>行级数据范围</b>
|
||||
* (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动,
|
||||
* 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。</p>
|
||||
|
||||
@@ -41,7 +41,7 @@ public class FormAnswerAdminController {
|
||||
public void edit(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody AnswerModels.EditRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataByAnswer(user, schId, body == null ? null : body.getAnswerId());
|
||||
adminService.edit(user, domain, body);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public class FormAnswerAdminController {
|
||||
public void softDelete(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("answerId") String answerId) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataByAnswer(user, schId, answerId);
|
||||
adminService.softDelete(user, domain, answerId);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public class FormAnswerAdminController {
|
||||
public void changeStatus(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody AnswerModels.ChangeStatusRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataByAnswer(user, schId, body == null ? null : body.getAnswerId());
|
||||
adminService.changeStatus(user, domain, body);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ public class FormAnswerAdminController {
|
||||
public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody AnswerModels.BatchRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataByAnswers(user, schId,
|
||||
body == null ? java.util.Collections.<String>emptyList() : body.getAnswerIds());
|
||||
return adminService.batchSoftDelete(user, domain, body);
|
||||
}
|
||||
|
||||
@@ -77,7 +78,8 @@ public class FormAnswerAdminController {
|
||||
public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody AnswerModels.BatchRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataByAnswers(user, schId,
|
||||
body == null ? java.util.Collections.<String>emptyList() : body.getAnswerIds());
|
||||
return adminService.batchChangeStatus(user, domain, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,16 @@ import java.util.List;
|
||||
/**
|
||||
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。
|
||||
*
|
||||
* <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p>
|
||||
* <p><b>权限两层各管一件事,缺一不可</b>(契约 v2 C4② 换了第①层的判据来源):</p>
|
||||
* <ul>
|
||||
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data}
|
||||
* 或统计 {@code /admin/stats} <b>任一</b>,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面;
|
||||
* 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);</li>
|
||||
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1;
|
||||
* <li>能不能进这个接口=<b>该表单 ALL 档</b>({@link FormAdminGuard#requireData};
|
||||
* 统计/列表/筛选/导出都吃同一道门。详情按 answerId 寻址、入口时手上还没有表单对象,
|
||||
* 故先由 uuid 反查所属表单再判({@code requireDataByAnswer});</li>
|
||||
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 两档解析 + 创建人/两表/角色,
|
||||
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校——
|
||||
* <p>所以"拿到 ALL 档入口的人"看到的仍是他范围内的数据——ALL 档在平台级表单上会收窄成本校提交行——
|
||||
* 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。</p>
|
||||
*
|
||||
* <p>身份纪律:租户域只从会话推导({@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");
|
||||
|
||||
@@ -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。
|
||||
* <p>GetUserMod 与其他子应用(Notice / svc-hall / 事项库 Cms / flow-center)统一走平台下发:经 SDK
|
||||
* {@code YschBaseOpenApi.getUserModule} 取当前用户在本应用({@code yh.app-auth.app-key})下的
|
||||
* 已授权模块<b>树</b>,层级由平台契约 {@code BoaModule.children} 承载,本侧仅映射为前端 layout
|
||||
* 期望的字段形状({@code page/modId/children})。
|
||||
* <p><b>target appCode(快应用上下文,方案 §6.2 / 任务书 T8)</b>:{@code /app/{appCode}/} 需要的是
|
||||
* <b>那个快应用</b>的模块树,而不是表单中心自己的。它依赖 UserBase {@code /oapi/user/modules} 的
|
||||
* 可选 appCode 参数(跨项目触点 H4),SDK 侧目前只有
|
||||
* {@code getUserModule(int userId, Integer schId)} 一个形态、<b>无 appCode 入参</b>。
|
||||
* H4 上线前本端点对该请求<b>返回空树并打 warn</b>——不静默返回本应用菜单,
|
||||
* 那会把「请求 A 应用的树」伪装成「A 应用就是这些菜单」。
|
||||
* 前端壳基础端点(对齐 UserBase BaseController 的 {@code /api/base/*} 形态)。
|
||||
*
|
||||
* <p>契约 v2 §2.4 之后本类只剩两个端点:</p>
|
||||
* <ul>
|
||||
* <li>{@code GetUserInfo}——会话用户本体,App.vue 引导的唯一请求(左导航改由三支路能力位自解析:
|
||||
* {@code /api/form/my/fillable}、{@code /api/form/my/managed}、{@code /api/form/my/capabilities});</li>
|
||||
* <li>{@code GetSchoolAll}——本校组织树(教师版),管理员两表选人弹窗的数据源;
|
||||
* 守卫从"应用设置 ∪ 表单设计两条模块行"换成 C4③ 的<b>校管理员 ∪ 超管</b>。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>{@code GetUserMod} 已随模块行整条退役而删除</b>(§19 作废-10):中心不再消费 UserBase 的
|
||||
* 授权模块树,B14(快应用目标应用树缺口)与跨项目触点 H4 随之消失——UserBase 侧不需要为表单中心
|
||||
* 登记任何模块行,也不需要给 {@code /oapi/user/modules} 加 appCode 参数。</p>
|
||||
*/
|
||||
@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} 形态)。
|
||||
*
|
||||
* <p><b>T10 落地的原因</b>:管理员两表的选人入口要"限本校树"(方案 §5.3 末段),而 UserBase 侧
|
||||
* 没有给子应用开放"按 app 过滤的组织树"端点,平台现行做法就是子应用自己代理组树——notice 与
|
||||
* flow-center 各有一份,本端点是同一口径的第三份({@code SchoolUserTreeService})。
|
||||
* 域只从会话推导({@code schId} 不是入参),持应用设置或表单设计任一模块行才能取树。</p>
|
||||
* <p>没有 {@code schId} 入参:域只从会话推导(契约 §7 身份行),因此候选天然限在本校树里
|
||||
* (跨校的人在接口层就给不出来)。T10 补件的原因是 UserBase 侧没有"按 app 过滤的组织树"端点,
|
||||
* 平台现行做法就是子应用自己代理组树——notice 与 flow-center 各有一份,本端点是同一口径的第三份。</p>
|
||||
*/
|
||||
@GetMapping("/api/base/GetSchoolAll")
|
||||
public List<SchoolModels.Node> getSchoolAll(BoaUser authUserWithDept) {
|
||||
Long domain = adminGuard.requireAdminAny(authUserWithDept, null,
|
||||
FormAdminGuard.MODULE_APP_SETTINGS, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
Long domain = adminGuard.requirePlatformAdmin(authUserWithDept, null);
|
||||
return schoolUserTreeService.teacherTree(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户已授权模块树(左侧菜单)。
|
||||
* <p><b>依赖 UserBase 侧数据</b>:本应用的 {@code app_module} 行({@code path} 须逐字对上
|
||||
* front 路由,见 T5 的 router/routes.ts)+ 角色/组授权,二者缺一即返回空菜单。
|
||||
* 这里<b>不保留本地 MENUS 兜底</b>——静默回退会把「平台未配模块/未授权」伪装成正常,
|
||||
* 与其他子应用口径一致,配置缺失应表现为空菜单并被发现。
|
||||
*
|
||||
* @param appCode 可选,目标应用的 app_code。缺省或等于 {@code form-center} 时返回本应用的树;
|
||||
* 指向其它应用时走 H4(UserBase 侧尚未上线)——返回空树并打 warn。
|
||||
*/
|
||||
@GetMapping("/api/base/GetUserMod")
|
||||
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept,
|
||||
@RequestParam(required = false) String appCode) {
|
||||
if (isForeignTarget(appCode)) {
|
||||
log.warn("[GetUserMod] 请求目标应用模块树 appCode={},UserBase /oapi/user/modules 的 "
|
||||
+ "appCode 参数(H4)未上线,SDK 无对应入参,按缺口返回空树", appCode);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (authUserWithDept == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
List<BoaModule> modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schId);
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 用户中心已按 BoaModule.children 下发模块树:本地不再重组层级,仅做前端字段改名映射
|
||||
return toFrontendTree(modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* BoaModule 树 → 前端 layout 期望的字段形状(递归,<b>只改名不组树</b>;Notice/svc-hall/Cms 同款口径)。<br>
|
||||
* 层级与兄弟顺序均由用户中心决定(customId 升序),本侧不重排;叶子 children 为空列表。<br>
|
||||
* 包级可见而非 private:供同包单测直接覆盖映射,避免为测试改写 {@code YschBaseOpenApi} 全局静态客户端。
|
||||
*/
|
||||
static List<Map<String, Object>> toFrontendTree(List<BoaModule> modules) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", m.getId());
|
||||
node.put("modId", resolveModId(m));
|
||||
node.put("name", m.getName());
|
||||
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
|
||||
node.put("page", m.getPath() != null ? m.getPath() : "");
|
||||
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
|
||||
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
||||
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
||||
node.put("usable", true);
|
||||
node.put("children", toFrontendTree(m.getChildren()));
|
||||
result.add(node);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的
|
||||
* {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。
|
||||
* 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。
|
||||
* 中心应用的模块行自 T10(手册 §2.2 v1.5 五行版)起 customId 恒为数字 200~204;非数字时回退主键 id
|
||||
* 只是防御性兜底,不再有"数据范围权限锚点"依赖这条映射(该协议已于 T10 退役,载体换成管理员两表)。
|
||||
*/
|
||||
private static Object resolveModId(BoaModule m) {
|
||||
String customId = m.getCustomId();
|
||||
if (customId != null && customId.trim().matches("\\d+")) {
|
||||
return Integer.valueOf(customId.trim());
|
||||
}
|
||||
return m.getId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.formcenter.model.MyModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.service.AnswerReadService;
|
||||
import com.yunhai.formcenter.service.FillableFormService;
|
||||
import com.yunhai.formcenter.service.FormAccessPolicy;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
@@ -28,7 +29,10 @@ import java.util.List;
|
||||
* <li><b>数据面身份只用 uuid</b>:answerId 是答卷 uuid,库内 id 不出现在任何外层(契约 §1/V5);</li>
|
||||
* <li><b>权限闸门</b>:普通用户只能看/改/删<b>自己提交</b>的答卷(submitter_id=会话用户),
|
||||
* "关于我的"只读(我是被代填人时可看不可改——改是填表人或管理员的动作);
|
||||
* 有管理权者另有 /api/form/admin/data/**(T4 已落地,scope 矩阵生效),两套口径不混用;</li>
|
||||
* 管理动作另有 {@code /api/form/admin/data/**}(按该表单 ALL 档把关,契约 v2 C4②),两套口径不混用;</li>
|
||||
* <li><b>菜单自解析的三支路数据源都挂在本前缀下</b>(§2.4/D5):{@code /fillable}(可填)、
|
||||
* {@code /managed}(我管理的)、{@code /capabilities}(校管理员能力位)——
|
||||
* UserBase 模块行整条退役后,前端不再从授权树里挑菜单,也不自行判权(只看这三把接口给什么);</li>
|
||||
* <li><b>本控制器没有状态端点</b>:填表人改状态在接口层即不存在(V14 的实现形态)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@@ -40,6 +44,7 @@ public class FormMyAnswerController {
|
||||
|
||||
private final AnswerReadService readService;
|
||||
private final FillableFormService fillableService;
|
||||
private final FormAccessPolicy accessPolicy;
|
||||
|
||||
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
|
||||
@GetMapping("/list")
|
||||
@@ -55,8 +60,8 @@ public class FormMyAnswerController {
|
||||
*
|
||||
* <p>与 {@code /my/about} 是<b>两个端点、两个谓词</b>,不是一个端点加 view 参数
|
||||
* (方案 §6.3 原文:"做成两个视图不做一个接口加参数(鉴权分支容易写漏)")。
|
||||
* 这里<b>不看模块行、不看契约 §8 范围矩阵</b>——数据管理面那套范围管的是"管理员能看谁的行",
|
||||
* 本面管的是"我自己的行",两套口径混用会让填表人不授权模块行就看不见自己的数据。</p>
|
||||
* 这里<b>不看角色、不看两档范围</b>——数据管理面那套范围管的是"管理员能看谁的行",
|
||||
* 本面管的是"我自己的行",两套口径混用会让填表人不进管理面就看不见自己的数据。</p>
|
||||
*/
|
||||
@GetMapping("/submit-list")
|
||||
public List<MyModels.ItemView> submitList(BoaUser user) {
|
||||
@@ -116,6 +121,35 @@ public class FormMyAnswerController {
|
||||
return fillableService.listFillable(user, appCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「可填写」清单的<b>管理面同族</b>端点(契约 v2 C3,{@code GET /api/form/my/managed}):
|
||||
* 创建人 ∪ form_admin 命中的行,行形状=fillable 行 + {@code role} 来源标记。
|
||||
*
|
||||
* <p>它是 D5"菜单自解析"的第二支路数据源。与 {@code /fillable} 一样<b>不接受外部传身份</b>,
|
||||
* 归属判定全在服务侧按会话算({@link FillableFormService#listManaged})。</p>
|
||||
*/
|
||||
@GetMapping("/managed")
|
||||
public List<MyModels.ManagedRow> managed(BoaUser user,
|
||||
@RequestParam(value = "appCode", required = false) String appCode) {
|
||||
requireSession(user);
|
||||
return fillableService.listManaged(user, appCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力位(契约 v2 §2.4 第三支路):前端"系统设置"入口的唯一数据源。
|
||||
*
|
||||
* <p>只有 {@code schoolAdmin} 一个布尔——超管恒 true(§2.1)。它<b>不是权限</b>:
|
||||
* 管理员两表、快应用注册、{@code GetSchoolAll} 各自在接口层按 C4③ 的角色门把关,
|
||||
* 这里只回答"要不要给这个人画这个入口"。</p>
|
||||
*/
|
||||
@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, "未登录");
|
||||
|
||||
@@ -11,7 +11,7 @@ import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的<b>数据查看 + 导出</b>授权(D3 裁决,
|
||||
* 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。
|
||||
* 编辑权=该表 ALL 档下的管理动作(契约 v2 C4②),本表不扩定义权)。
|
||||
*
|
||||
* <p>{@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与
|
||||
* 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为
|
||||
|
||||
@@ -13,7 +13,8 @@ import javax.persistence.UniqueConstraint;
|
||||
* customId 权限锚点行(方案 §7)。
|
||||
*
|
||||
* <p><b>它只升级数据范围,不是页面入口凭证</b>(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权
|
||||
* 模块行判({@code FormAdminGuard}),这张表只让 {@code DataScopeService} 把范围从"仅本人提交"升到
|
||||
* 角色门判({@code FormAdminGuard}+{@code FormAccessPolicy.isSchoolAdmin}),这张表只让 {@code DataScopeService}
|
||||
* 把范围从"SELF"升到
|
||||
* 本校表单=全部、基础表单=本校提交。</p>
|
||||
*
|
||||
* <p>{@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权
|
||||
|
||||
@@ -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} 来源标记数组,菜单自解析的"管理"支路数据源。
|
||||
*
|
||||
* <p>{@code role} 取值闭集 {@code creator}(C16 判定)/{@code form_admin}(两表命中),
|
||||
* 两个来源同时命中时数组有两个元素——前端要能说出"你为什么管得动这张表"。</p>
|
||||
*
|
||||
* <p><b>与 fillable 的两处刻意差别</b>:① 不做遮蔽去重(管理清单要"我能碰的每一张表",
|
||||
* 同 code 的本校草稿与平台级表单是两条不同的可操作资产,藏掉一条就再也进不去);
|
||||
* ② 不限生命周期(草稿/停用行同样要出现在管理支路,否则建了草稿的人找不到入口继续编辑)。</p>
|
||||
*/
|
||||
@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<String> role;
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力位(契约 v2 §2.4 三支路自解析的第三支):前端"系统设置"入口的唯一数据源。
|
||||
*
|
||||
* <p>只有这一个布尔,是给前端画菜单用的——<b>不是权限</b>:接口侧每个端点各自按 C4 的角色门把关,
|
||||
* 前端藏掉入口不等于没权限(契约 §7 身份行)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Capabilities {
|
||||
/** 校管理员(超管恒 true):管理员两表 / 快应用注册 / GetSchoolAll 的入口位 */
|
||||
private boolean schoolAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。
|
||||
* 数据面读路径(列表/详情/统计/导出)出入参模型(契约 v2 §2.3 两档 + §9,方案 §5.5)。
|
||||
*
|
||||
* <p>三条对外纪律:</p>
|
||||
* <ul>
|
||||
|
||||
@@ -25,6 +25,13 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
|
||||
|
||||
Optional<Answer> 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<Answer> findBySchIdAndUuidIn(@Param("schId") Long schId, @Param("uuids") Collection<String> uuids);
|
||||
|
||||
/**
|
||||
* 两段式列表的第二段(方案 §5.5 铁律①):<b>按当页 id 批量取整文档</b>。
|
||||
* 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json};
|
||||
|
||||
@@ -19,4 +19,10 @@ public interface FormAdminRepository extends JpaRepository<FormAdmin, Long> {
|
||||
Optional<FormAdmin> findByIdAndFormUuid(Long id, String formUuid);
|
||||
|
||||
boolean existsByFormUuidAndUserId(String formUuid, Integer userId);
|
||||
|
||||
/**
|
||||
* 按人反查"我被授权了哪些表单"(T14 的 managed 清单,C3)。
|
||||
* {@code idx_form_admin_user} 就是为这条留的(方案 §8 登记的 v2 入口,本批兑现)。
|
||||
*/
|
||||
List<FormAdmin> findByUserId(Integer userId);
|
||||
}
|
||||
|
||||
@@ -53,4 +53,17 @@ public interface FormRepository extends JpaRepository<Form, Long> {
|
||||
*/
|
||||
@Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 and f.status = 1")
|
||||
List<Form> findPublishedInDomain(@Param("schIds") Collection<Long> schIds);
|
||||
|
||||
/**
|
||||
* 「我管理的」清单取域(契约 v2 C3):域内<b>未软删</b>的表单,不限生命周期——
|
||||
* 草稿与停用行都要在管理支路里出现(创建人要能继续改自己没发布的表)。
|
||||
* 归属判定(创建人 / form_admin)在服务侧做,理由同 {@link #findLiveBySch}:
|
||||
* 可空参数与 OR 型 JPQL 在 MySQL 上有类型推断坑(P10),且一校的表单量级是个位数到几百。
|
||||
*/
|
||||
@Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 order by f.updateTime desc, f.id desc")
|
||||
List<Form> findLiveInDomain(@Param("schIds") Collection<Long> schIds);
|
||||
|
||||
/** 按 uuid 批量取头表(T14 守卫:由答卷反查所属表单以判 ALL 档;uk_form_uuid 唯一) */
|
||||
@Query("select f from Form f where f.uuid in (:uuids)")
|
||||
List<Form> findByUuidIn(@Param("uuids") Collection<String> uuids);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ package com.yunhai.formcenter.service;
|
||||
* 管理员两表的<b>只读判定端口</b>(T10 方案 §5.1):范围解析与守卫都从这里问"这个人在不在
|
||||
* {@code school_admin} / {@code form_admin} 里",生产实现是 {@code AdminDomainService}(直查本库两表)。
|
||||
*
|
||||
* <p><b>为什么做成端口</b>:{@code DataScopeService} 与 {@code FormAdminGuard} 都只消费这两个布尔判定,
|
||||
* <p><b>为什么做成端口</b>:{@code DataScopeService} 与 {@code FormAccessPolicy} 都只消费这两个布尔判定,
|
||||
* 加宽一侧(比如将来按人反查"我管理的表单",方案 §8 的 v2 入口)不该把仓库与写侧逻辑渗进解析器;
|
||||
* 同 {@link ModuleTreeSource}/{@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。</p>
|
||||
* 同 {@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。</p>
|
||||
*
|
||||
* <p><b>口径</b>:判定只认会话身份({@code (schId, userId)} / {@code (formUuid, userId)}),
|
||||
* 查不到即 false(fail-closed)——残留行(教师离校)因会话失效而永远走不到这里,方案 §8。</p>
|
||||
|
||||
@@ -44,10 +44,12 @@ import java.util.List;
|
||||
* 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>两层权限都过</b>(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard},
|
||||
* 决定"能不能用数据管理接口"),每条答卷再过一次<b>行级范围</b>——判据是四条读出路径共用的
|
||||
* <p><b>两层权限都过</b>(P30 的收紧点,T4 落地;契约 v2 C4② 换了入口判据):控制器先按
|
||||
* <b>该表单 ALL 档</b>把关({@code FormAdminGuard#requireDataByAnswer(s)},决定"能不能用数据管理接口"),
|
||||
* 每条答卷再过一次<b>行级范围</b>——判据是四条读出路径共用的
|
||||
* {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此<b>列表里看不见的答卷,
|
||||
* 编辑/软删/改状态也做不了</b>:教师即便被授权了数据管理模块行,也只能动自己范围内的行。</p>
|
||||
* 编辑/软删/改状态也做不了</b>:ALL 档入口进来之后,行级仍按同一份范围规格判——"契约 v2 §2.3
|
||||
* 的租户限定变体"下,本校之外的行(含 {@code submitter_sch_id=NULL} 的存量行)照改不动。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据面读服务(列表 / 详情 / 筛选候选,契约 §8/§9,方案 §5.5)。
|
||||
* 数据面读服务(列表 / 详情 / 筛选候选,契约 v2 §2.3 两档范围 + §6/§9 契约,方案 §5.5)。
|
||||
*
|
||||
* <p><b>与 {@link AnswerScopeQueryBuilder} 的分工</b>:本类负责"把请求翻译成查询规格"
|
||||
* (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。
|
||||
@@ -46,8 +46,8 @@ import java.util.Map;
|
||||
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
|
||||
*
|
||||
* <p><b>末尾的"我的填写"段是另一条线,不要与上面四条混读</b>:那里的可见集来自
|
||||
* {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是契约 §8 的范围矩阵
|
||||
* (填表人不授权任何模块行也能看自己的数据,方案 §6.3"我的填写两层落点");
|
||||
* {@code submitter_id}/{@code subject_id}=会话用户的<b>归属判定</b>,不是 §2.3 的两档范围
|
||||
* (填表人不进任何管理面也能看自己的数据,方案 §6.3"我的填写两层落点");
|
||||
* 它也不接受 {@code schId} 外部传入,租户域同样只从会话推导(V17)。</p>
|
||||
*/
|
||||
@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)。
|
||||
*
|
||||
* <p>与数据管理面的区别:那条路径的范围来自契约 §8 矩阵({@link DataScopeService})且要先有
|
||||
* {@code /admin/data} 模块行;这里<b>不看模块行、不看范围矩阵</b>,只看"这条数据是不是我的",
|
||||
* 所以填表人(学生)不授权任何管理模块行也能用自己的两视图。</p>
|
||||
* <p>与数据管理面的区别:那条路径的范围来自 {@link DataScopeService} 的两档解析且入口按 ALL 档
|
||||
* 把关(契约 v2 C4②);这里<b>不看角色、不看范围</b>,只看"这条数据是不是我的",
|
||||
* 所以填表人(学生)不进任何管理面也能用自己的两视图。</p>
|
||||
*/
|
||||
public List<MyModels.ItemView> listMine(BoaUser user, boolean bySubject) {
|
||||
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
|
||||
|
||||
@@ -29,10 +29,10 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* <b>数据范围查询构造器</b>(方案 §6.4 红线 / 契约 §8 红线的唯一实现处):
|
||||
* <b>数据范围查询构造器</b>(方案 §6.4 红线 / 契约 v2 §2.3 红线的唯一实现处):
|
||||
* 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关,
|
||||
* <em>全部</em>调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、
|
||||
* 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。
|
||||
* 统计一调返回全校分布,两档范围就白定了),故本类不接受"跳过范围"的开关参数。
|
||||
*
|
||||
* <p>一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)}
|
||||
* → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询)
|
||||
|
||||
@@ -97,9 +97,22 @@ public class DataScopeService {
|
||||
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
|
||||
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。</p>
|
||||
*/
|
||||
private static boolean isCreator(BoaUser user, Form form) {
|
||||
/**
|
||||
* 创建人判定(C16):{@code creator_id}==会话 uid <em>或</em> {@code create_by}==会话账号名。
|
||||
*
|
||||
* <p>兜底那半不是历史包袱而是必需项:{@code create_by} 存的是账号名(String),与两表的 user_id
|
||||
* 标识体系不同,存量行 {@code creator_id} 未回填时只有账号名可比。存量回填由一次性对照 UserBase
|
||||
* 的脚本/人工完成(160 现存 form 行个位数),回填前后判定结果一致。</p>
|
||||
*
|
||||
* <p>公开给 {@link FormAdminGuard} 复用(C4①"定义域写含该表单创建人"与 ALL 档
|
||||
* 必须是同一条判定,两处各写一份就会漂移)。</p>
|
||||
*/
|
||||
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());
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p><b>四步,一步不多</b>:</p>
|
||||
* <ol>
|
||||
@@ -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 命中的行。
|
||||
*
|
||||
* <p>取域与「我可填」同为"会话校 ∪ 平台级",但<b>不做遮蔽去重、不限生命周期</b>
|
||||
* (理由见 {@link MyModels.ManagedRow} 的类注释)。排序沿仓库的 {@code update_time desc}
|
||||
* ——管理动作关心"最近碰过哪张",不像填报清单那样有截止时间这个天然主序。</p>
|
||||
*
|
||||
* @param appCode 可选;非空=只要该应用下的行
|
||||
*/
|
||||
public List<MyModels.ManagedRow> listManaged(BoaUser user, String appCode) {
|
||||
Long schId = FormAccessPolicy.requireSessionSchId(user, "当前身份无学校归属");
|
||||
String cleanApp = appCode == null ? "" : appCode.trim();
|
||||
List<Long> domain = Form.PLATFORM_SCH_ID == schId
|
||||
? Collections.singletonList(Form.PLATFORM_SCH_ID)
|
||||
: Arrays.asList(schId, Form.PLATFORM_SCH_ID);
|
||||
|
||||
List<String> administered = new ArrayList<>();
|
||||
for (FormAdmin grant : formAdminRepository.findByUserId(user.getId())) {
|
||||
if (grant.getFormUuid() != null) {
|
||||
administered.add(grant.getFormUuid());
|
||||
}
|
||||
}
|
||||
|
||||
List<MyModels.ManagedRow> rows = new ArrayList<>();
|
||||
for (Form form : formRepository.findLiveInDomain(domain)) {
|
||||
if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
|
||||
continue;
|
||||
}
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):模块行退役后,这里只剩<b>角色门 + 租户域推导</b>两件事。
|
||||
*
|
||||
* <p><b>T4 起口径=授权模块集</b>(P21 裁决的到期替换:T2/T3 的 roleId∈{2,7} 硬编码整体移除、不保留回落)——
|
||||
* "中心管理员"就是<b>被授权对应模块行的人</b>,与平台其它子应用同构(左导航与后端把关用的是同一棵树,
|
||||
* 因此不存在"菜单看得见、接口调不动"的两套口径):</p>
|
||||
* <ul>
|
||||
* <li>定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
|
||||
* 应用设置={@code /admin/app}(T10 方案 §3:字典管理行撤销、迁入应用设置页内 tab,
|
||||
* <b>字典 API 摊这一行把关</b>;手册 §2.2 表 v1.5 起是五行版)。</li>
|
||||
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、
|
||||
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定,
|
||||
* 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。</li>
|
||||
* </ul>
|
||||
* <p>三段判定逐字对齐 C4(《契约v2》§0.4):</p>
|
||||
* <ol>
|
||||
* <li><b>定义域</b>——新建/另存副本=<b>超管 ∪ 校管理员</b>(G1 用户裁决定稿的"创建权");
|
||||
* 对某张已存在表单的写(save/publish/disable/enable/softDelete/config/statusDefs)
|
||||
* 与读(list/get/versions/version/filterFields)=<b>超管 ∪ 校管理员 ∪ 该表单创建人</b>
|
||||
* (创建人保留定义权,D6 不设移交);</li>
|
||||
* <li><b>数据域</b>——{@code /admin/data/**} 读写=<b>该表单 ALL 档持有者</b>;
|
||||
* 行级仍由 {@link AnswerScopeQueryBuilder} 把关(V55 唯一出口),本守卫只回答"能不能进这个面";
|
||||
* 原 {@code canManage}(模块行判"有没有代填权")并入同一条 ALL 档判定,由
|
||||
* {@code AnswerService#requireSubject} 消费;</li>
|
||||
* <li><b>平台管理域</b>——管理员两表、app 注册(C17)、{@code GetSchoolAll}=<b>校管理员 ∪ 超管</b>。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + 管理员两表决定
|
||||
* ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
|
||||
* 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。
|
||||
* <b>管理员两表不充当页面入口凭证</b>(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里
|
||||
* 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。</p>
|
||||
* <p><b>与 T4~T10 的口径差别</b>:原来"能不能进页面"的事实源在 UserBase 授权模块树
|
||||
* ({@code ModuleTreeSource}),现在在本库的角色与所有权事实里——两条树不复存在,
|
||||
* 也就不再有"菜单看得见、接口调不动"的两套口径(§19 作废-12)。
|
||||
* 租户域推导规则<b>一条没动</b>:超管可显式指定目标域(含 0=平台级),校级只能本校且传 0 直接拒。</p>
|
||||
*
|
||||
* <p><b>fail-closed</b>:未登录拒;无学校归属拒;表单取不到与"不是你域内的表单"同口径回答
|
||||
* (uuid 不可枚举,存在性本身就是探测面)。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义域·针对某张表单:超管 ∪ 校管理员 ∪ <b>该表单创建人</b>(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/**} 读写):该表单 <b>ALL 档</b>持有者(C4②)。
|
||||
*
|
||||
* <p>校管理员在平台级表单上拿到的是 ALL 档的<b>租户限定变体</b>(D9),照样算 ALL 档持有者——
|
||||
* 进得来,但行集由 {@link AnswerScopeQueryBuilder} 按同一份规格收窄。</p>
|
||||
*/
|
||||
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 档。
|
||||
*
|
||||
* <p>原形态是"入口按模块行取域、行级由 scope 构造器判";模块行退役后入口判据也是 ALL 档,
|
||||
* 所以必须先把表单找出来——{@code /admin/data/{edit,softDelete,changeStatus}} 与 {@code detail}
|
||||
* 的请求体里只有 {@code answerId}。</p>
|
||||
*/
|
||||
public Long requireDataByAnswer(BoaUser user, Long targetSchId, String answerUuid) {
|
||||
Long domain = dataDomain(user, targetSchId);
|
||||
Map<String, Form> byUuid = formsOfAnswers(domain, Collections.singletonList(answerUuid));
|
||||
Form form = answerUuid == null ? null : byUuid.get(answerUuid.trim());
|
||||
return requireData(user, domain, form);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面入口·批量:批量里的答卷<b>可能跨表单</b>,所以逐张表都要判 ALL 档,
|
||||
* 任意一张不在档内就整把请求拒(不放进服务里再逐条拒——那样 {@code V14}"填表人不能改状态"
|
||||
* 会从批量通道漏出去)。
|
||||
*/
|
||||
public Long requireDataByAnswers(BoaUser user, Long targetSchId, List<String> answerUuids) {
|
||||
Long domain = dataDomain(user, targetSchId);
|
||||
if (FormAccessPolicy.isSuperAdmin(user)) {
|
||||
return domain;
|
||||
}
|
||||
requireSession(user);
|
||||
Map<String, Form> 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<String, Form> formsOfAnswers(Long domain, List<String> answerUuids) {
|
||||
List<String> clean = new ArrayList<>();
|
||||
for (String uuid : answerUuids) {
|
||||
if (uuid != null && !uuid.trim().isEmpty()) {
|
||||
clean.add(uuid.trim());
|
||||
}
|
||||
}
|
||||
Map<String, Form> result = new HashMap<>();
|
||||
if (clean.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Answer> answers = answerRepository.findBySchIdAndUuidIn(domain, clean);
|
||||
List<String> formUuids = new ArrayList<>();
|
||||
for (Answer answer : answers) {
|
||||
if (answer.getFormUuid() != null && !formUuids.contains(answer.getFormUuid())) {
|
||||
formUuids.add(answer.getFormUuid());
|
||||
}
|
||||
}
|
||||
Map<String, Form> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否具备管理权(超管,或被授权定义管理/数据管理任一模块行)。<b>不含租户域核对</b>——
|
||||
* 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。
|
||||
*
|
||||
* <p>现用途=契约 §5 的代填分支({@code subject_id} 能否指向他人):普通用户只能=自己,
|
||||
* 有管理权者可代填(方案 §6.3/V17)。口径与 P21 过渡期的 roleId∈{2,7} 等宽(定义或数据管理),
|
||||
* 判定依据换成授权模块集。</p>
|
||||
*/
|
||||
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<String> paths = authorizedPaths(user);
|
||||
return paths.contains(MODULE_DATA_MANAGE) || paths.contains(MODULE_FORM_DESIGN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一条校验的"任一模块行命中即放行"形态。
|
||||
*
|
||||
* <p>现用途=统计:手册 §2.2 明示统计行"可并入数据管理,二选一",所以两种配法都算有统计权;
|
||||
* 只有一个模块行要求的接口请用 {@link #requireAdmin}。</p>
|
||||
*/
|
||||
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<String> held = authorizedPaths(user);
|
||||
for (String modulePath : modulePaths) {
|
||||
if (held.contains(modulePath)) {
|
||||
// 命中即按该模块行走完整租户域校验(复用同一段拒绝逻辑,不在这里另写一份域判定)
|
||||
return requireAdmin(user, targetSchId, modulePath);
|
||||
}
|
||||
}
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无该模块权限(需任一):" + String.join(" 或 ", modulePaths));
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面<b>读出路径</b>的入口判定(T10 方案 §5.2):列表 / 筛选候选 / 统计 / 导出四条按表单寻址的
|
||||
* 读接口用它,返回本次查询的租户域。与 {@link #requireAdmin} 的差别只有两处:
|
||||
* <ol>
|
||||
* <li><b>数据管理或统计任一模块行即可</b>(手册 §2.2 的"统计可并入数据管理,二选一"推广到整个
|
||||
* 数据面——只授统计行的人不该在列表页拿到 403 而菜单里又确实有数据面);</li>
|
||||
* <li><b>按表单归属域判</b>,于是基础表单({@code sch_id=0})在本校域下可读(T4 起就是这个口径,
|
||||
* 本方法不收窄它),而他校表单与"表单不存在"同口径回答(uuid 不可枚举,契约 §1)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>进来之后能看哪些行仍然只由 {@link DataScopeService} 决定</b>:管理员两表在这里不作入口凭证
|
||||
* (T10 任务书 §0-4 守卫红线),它们只把范围从"仅本人提交"升到"全部/本校提交"。</p>
|
||||
*
|
||||
* <p>详情面(按 answerId 寻址)入口时还没有表单对象,沿用 {@link #requireAdminAny} 取域 +
|
||||
* 构造器 {@code visible()} 的行级把关——同一份范围规格,可见集与列表必然同集(V27 的断言形态)。</p>
|
||||
*/
|
||||
public Long requireDataView(BoaUser user, Long targetSchId, Form form) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
if (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<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ public class FormDefService {
|
||||
|
||||
/**
|
||||
* 数据面读接口<b>入口把关</b>用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定,
|
||||
* 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}——
|
||||
* 取不到返回 null。域与角色的判断全在 {@code FormAdminGuard#requireData}——
|
||||
* 那里才知道"他校表单与不存在的表单要用同一口径回答",本方法刻意不给拒绝话术。
|
||||
*/
|
||||
public Form findFormForGuard(String formUuid) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 会话用户在本应用下的<b>已授权模块树</b>来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
|
||||
*
|
||||
* <p>本树回答<b>管理权</b>一类问题(执行手册 §5 P21/P30 裁决落地口径):定义管理/数据管理/统计/
|
||||
* 应用设置=被授权对应模块行(path 命中,手册 §2.2 表 v1.5 五行版)。</p>
|
||||
*
|
||||
* <p><b>T10 起数据范围不再从这棵树出</b>:{@code customId} 权限锚点协议退役(管理员体系方案 §7),
|
||||
* 范围升级改由本库的管理员两表提供({@link AdminScopeSource})。path 空的锚点形态本身仍存在于
|
||||
* <b>快应用面</b>(导入/导出按钮,前端 {@code modulePerms} 消费,T9/P76 产物),只是中心后端不再扫描它。</p>
|
||||
*
|
||||
* <p>做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。
|
||||
* 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责);
|
||||
* 本地 IT 不启动 UserBase/CAS,注入桩即可断言权限矩阵本身,真实链路 T9 联调验。</p>
|
||||
*
|
||||
* <p><b>失败口径=fail-closed</b>:不可达或异常按"无任何授权模块"处理(管理权被拒、范围回落到仅本人提交),
|
||||
* 与契约 §8"全不命中=仅本人提交(宁紧勿松)"同向。</p>
|
||||
*/
|
||||
public interface ModuleTreeSource {
|
||||
|
||||
/** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */
|
||||
List<BoaModule> authorizedModules(BoaUser user);
|
||||
}
|
||||
@@ -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 运行时链路)。
|
||||
*
|
||||
* <p>{@code schId} 传会话学校=把授权限定在该学校上下文(UserBase 侧只取该校子树内的真实归属组参与判定);
|
||||
* 无学校归属的身份(区域组/根直属)传 null,按 UserBase 语义取全部归属组。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PlatformModuleTreeSource implements ModuleTreeSource {
|
||||
|
||||
@Override
|
||||
public List<BoaModule> authorizedModules(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
|
||||
try {
|
||||
List<BoaModule> modules = YschBaseOpenApi.getUserModule(user.getId(), schId);
|
||||
return modules == null ? Collections.<BoaModule>emptyList() : modules;
|
||||
} catch (Exception e) {
|
||||
// fail-closed:取不到树就当没有授权——管理权拒、数据范围回落到仅本人提交(接口注释)
|
||||
log.warn("[模块树] UserBase /oapi/user/modules 不可达,按无授权处理 userId={} schId={}:{}",
|
||||
user.getId(), schId, e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 不跑单测)。<br>
|
||||
* 映射部分锁定三个易错点:modId 取数字 custom_id、children 恒为数组(前端无判空递归)、
|
||||
* path 缺失归空串。appCode 部分锁定「指向别的应用时<b>绝不打 SDK</b>」——
|
||||
* 单测里 SDK 静态客户端未装配,误走本应用分支会直接抛异常而不是返回空树。
|
||||
* 前端壳基础端点单测(契约 v2 §2.4 / 验收 <b>V72</b>)。
|
||||
*
|
||||
* <p>原 {@code GetUserMod} 的 BoaModule 树映射与"外指 appCode 空树不回落"(P3 裁决)两批用例
|
||||
* 随端点退役整体删除——模块行整条退役后中心不再消费授权树,那套映射没有调用方了
|
||||
* (§19 作废-10;B14 缺口与跨项目触点 H4 一并消失)。</p>
|
||||
*
|
||||
* <p>留两条断言:① {@code GetUserInfo} 仍原样回话会话用户(App.vue 引导的唯一请求);
|
||||
* ② <b>反射级"端点真拆"断言</b>——{@code /api/base/GetUserMod} 这个映射在控制器上不存在。
|
||||
* 拆面必须真拆(任务书 §1 红线 10):留着空实现或只删调用方都算双轨。</p>
|
||||
*/
|
||||
@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<Map<String, Object>> tree = FormBaseController.toFrontendTree(Arrays.asList(parent, otherRoot));
|
||||
|
||||
assertEquals(2, tree.size(), "顶层只应有两个根,子节点不得浮到顶层");
|
||||
Map<String, Object> 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<Map<String, Object>> children = (List<Map<String, Object>>) 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<Map<String, Object>> tree = FormBaseController.toFrontendTree(Arrays.asList(configuredLeaf, notExpanded));
|
||||
|
||||
for (Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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.<BoaModule>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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:加列 ====================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ==================== 批量 ====================
|
||||
|
||||
@@ -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<AnswerModels.BatchItemView> 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:遮蔽后的钉版溯源 ====================
|
||||
|
||||
@@ -27,10 +27,10 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
* 测试直接造它——<b>这正是 V17 的断言方式</b>:服务签名里只有 {@code BoaUser} 与请求体,
|
||||
* 请求体没有 {@code schId/submitterId} 字段,身份只能来自会话对象。</p>
|
||||
*
|
||||
* <p><b>两个外部事实源用桩顶掉</b>({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS):
|
||||
* {@link ModuleTreeSource}(授权模块集=P21 的管理权口径 + customId 范围升级位)与
|
||||
* {@link SubjectDirectory}(P33 的代填存在性)。生产实现是平台签名调用,接线在 T9 联调验;
|
||||
* 本轮取证的是"规则本身对不对",桩把事实源钉住即可。</p>
|
||||
* <p><b>外部事实源用桩顶掉</b>({@link TestGatewayConfiguration},本地 IT 不启动 UserBase/CAS):
|
||||
* {@link SubjectDirectory}(P33 的代填/代录存在性,生产实现是平台签名调用)。
|
||||
* 契约 v2 之后<b>没有第三个桩了</b>:原 {@code ModuleTreeSource}(授权模块集)随模块行整条退役删除,
|
||||
* 管理权与数据范围的事实源都落在本库(角色 + 两表 + creator_id),IT 直接插真行。</p>
|
||||
*/
|
||||
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 命中校领导临时清单,天然校管理员)。
|
||||
*
|
||||
* <p>T4 起管理权看授权模块集(P21 口径),单有 roleId 不再等于能管理——所以"管理员"在测试里
|
||||
* 就是这两件事一起造。需要"管理员但没被授权"的反例时用 {@link #user(Integer, Long, Integer)}
|
||||
* 再 {@code moduleTree.clear(id)}。</p>
|
||||
* <p>模块行退役后没有"再授权一次"的第二件事:管理权与数据范围来自同一份角色/所有权事实。
|
||||
* 需要"不是校管理员"的反例时用 {@link #user(Integer, Long, Integer)} 配一个不在清单里的 roleId。</p>
|
||||
*/
|
||||
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() {
|
||||
|
||||
@@ -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 <b>P21 裁决的 T4 到期替换</b>:授权模块集口径,roleId 硬编码整体移除;
|
||||
* T10 再改:字典管理行撤、应用设置行接手 + 数据面入口判定 {@code requireDataView})。
|
||||
* 管理面守卫单测(契约 v2 C4,验收 <b>V72</b> 的守卫半边)。
|
||||
*
|
||||
* <p>口径:能进哪个管理面=被授权哪个模块行(手册 §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"超管不受单校开通约束")。</p>
|
||||
* <p>原 T4~T10 形态的"授权模块行→能不能进页面"用例({@code ModuleTreeSource} 桩 + path 匹配)
|
||||
* 随模块行整条退役删除(§19 作废-10)。这里改按 C4 三段角色门逐格断言,并且<b>把租户域推导
|
||||
* 一并钉住</b>——那半套规则(超管可指定含 0 的目标域、校级只能本校且传 0 即拒)从 T4 起一字未动,
|
||||
* 换的只是"进不进得来"的判据来源。</p>
|
||||
*
|
||||
* <p>桩={@link ModuleTreeSource} 的 lambda——本类不启 Spring、不连库,验证的是"给定授权集合,
|
||||
* 判定对不对"。真实树来自 UserBase 的链路在 T9 联调验。</p>
|
||||
* <p>两表与创建人判定都用真实现({@link InMemoryAdminScopeSource} + {@link DataScopeService}),
|
||||
* 只有"按答卷反查表单"用到仓库(Mockito 桩)——守卫的逻辑本身一行都不允许是桩。</p>
|
||||
*/
|
||||
@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<BoaModule>());
|
||||
List<BoaModule> 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<String> 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<BoaModule> rows = new ArrayList<>();
|
||||
for (String path : paths) {
|
||||
BoaModule module = new BoaModule();
|
||||
module.setPath(path);
|
||||
module.setChildren(new ArrayList<BoaModule>());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 联调验)。
|
||||
*
|
||||
* <p>建模的是 UserBase 侧的页面路由行(手册 §2.2:{@code path} 逐字对上 front 路由)。缺省<em>不给任何
|
||||
* 模块</em>——与生产侧 fail-closed 同向(无授权=无管理权、范围回落到仅本人提交),需要权限的用例显式授权。</p>
|
||||
*
|
||||
* <p>T10 起本桩只剩 path 一种形态:{@code customId} 权限锚点协议退役,数据范围升级改由管理员两表提供
|
||||
* ({@link InMemoryAdminScopeSource})。</p>
|
||||
*/
|
||||
public class InMemoryModuleTreeSource implements ModuleTreeSource {
|
||||
|
||||
private final Map<Integer, List<BoaModule>> byUser = new ConcurrentHashMap<>();
|
||||
|
||||
/** 授权页面路由模块行(path 逐字用手册 §2.2 的那几条) */
|
||||
public void grantPaths(Integer userId, String... paths) {
|
||||
List<BoaModule> 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<BoaModule> 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.<BoaModule>emptyList())));
|
||||
}
|
||||
|
||||
private List<BoaModule> 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<BoaModule>());
|
||||
return module;
|
||||
}
|
||||
|
||||
/** 便于用例一行授权"数据管理 + 定义管理"(=手册 §2.2 的中心管理员常见配法) */
|
||||
public void grantManageDefaults(Integer userId) {
|
||||
grantPaths(userId, FormAdminGuard.MODULE_DATA_MANAGE, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 行软删(让谓词真实参与)。</p>
|
||||
*
|
||||
* <p><b>口径边界(报告要点名)</b>:测的是 <b>service+SQL 层</b>延迟,不含 HTTP/CAS/JSON 序列化
|
||||
* 的用户面开销,也不含 UserBase 签名调用的网络耗时(模块树被桩顶掉;P44 单独量化<b>每请求取树次数</b>)。
|
||||
* 的用户面开销,也不含 UserBase 签名调用的网络耗时(契约 v2 起中心不再取模块树,P44 度量对象已消失)。
|
||||
* 本机 MySQL 8.0.45 与 160 容器网 mysql8 同大版本。</p>
|
||||
*/
|
||||
@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<String> report = new ArrayList<>();
|
||||
private final List<String> sqlLog = new ArrayList<>();
|
||||
private final AtomicInteger userSeq = new AtomicInteger(910000);
|
||||
|
||||
/** 模块树桩的计数版(P44):桩顶掉签名调用的同时把<b>调用次数</b>留下来,否则 P44 只能靠读代码猜 */
|
||||
static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
|
||||
final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public List<BoaModule> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 压测补探针(<b>控制器形状</b>):{@code Perf200kProbe} 直调服务层,量到的"每请求取树次数"少了一次
|
||||
* 控制器侧 {@code FormAdminGuard.requireAdmin} 的取树——那正是 P44 问的东西("每请求 1~2 次"里的 2 从哪来)。
|
||||
* 本探针打 {@link FormAnswerReadController} 的端点方法(会话用户 + 请求体,与线上调用形状一致),
|
||||
* 把 P44 的数字钉成<b>请求级</b>,并顺带给出 V10 三项在含守卫形状下的延迟。
|
||||
*
|
||||
* <p>数据复用 {@code bsch_formcenter_perf}(由 {@code Perf200kProbe} 造好,20 万答卷 / 78 万索引行)。
|
||||
* 命名不带 Test/IT 后缀 → 默认门禁不收集;手点:{@code mvn -B -o test -Dtest=PerfTreeCallsProbe}。</p>
|
||||
*/
|
||||
@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<BoaModule> 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<String> 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<Long> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user