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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user