- 新增 school_admin/form_admin 两表(sql/t10_admin_domain.sql,含 answer.submitter_sch_id 加列)与 AdminDomainController/AdminDomainService/SchoolUserTreeService - DataScopeService 范围体系按 P80 验收口径落地:入口只认模块行、查询域=会话校、两表只升范围;新增 SchoolScopeImplementor - fc:scope:all / SCOPE_ALL_CUSTOM_ID / anchorCustomIds / MODULE_DICT 锚点全链路退役,全仓引用归零;h2 登记脚本 6→5 行(快应用段逐字未动) - AnswerScopeQueryBuilder 校域不变式:answer.sch_id 恒=会话校,基础过滤 sch_id+app_code+code+is_deleted 不变 - FormAdminGuard:targetSchId=0(基础表单)仅超管可维护;FormAdminController/AnswerReadController 等按管理员范围收口 - 测试基线 190→219/0/0/0:DataScopeServiceTest 22、FormAdminGuardTest 14、AdminScopeIT 8、AdminDomainIT 10、AnswerReadIT 改写 1、FormBaseControllerTest 7 等
This commit is contained in:
@@ -7,14 +7,14 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。
|
||||
* 授权模块树的权限键扁平化(方案 §6.1 第②层)。
|
||||
*
|
||||
* <p>模块行有两种身份,<b>必须分开取</b>:</p>
|
||||
* <ul>
|
||||
* <li>{@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);</li>
|
||||
* <li>{@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位,
|
||||
* 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。</li>
|
||||
* </ul>
|
||||
* <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>
|
||||
*/
|
||||
@@ -23,31 +23,17 @@ public final class FormModuleKeys {
|
||||
private FormModuleKeys() {
|
||||
}
|
||||
|
||||
/** 授权页面路由集合(path 空的权限锚点不在内) */
|
||||
/** 授权页面路由集合(path 空的权限位不在内) */
|
||||
public static Set<String> paths(List<BoaModule> tree) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collect(tree, true, result);
|
||||
collect(tree, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限锚点的 customId 集合:<b>只收 path 为空的节点</b>(契约 §8「锚点行形态」注记 / P47)。
|
||||
*
|
||||
* <p>数据范围升级位({@code fc:scope:all})按契约只应挂在<b>不进导航的 path 空行</b>上,
|
||||
* 扫描必须按这个形态收:带 path 的导航行若误配了该值,不该把看数据的人悄悄升级成"全校全部"
|
||||
* ——宽方向的误配同样是不受控的授权膨胀。</p>
|
||||
* 递归展平。取不出 path 的节点自然被跳过,子节点照常下钻(页面行可以挂在分组层下面)。
|
||||
*/
|
||||
public static Set<String> anchorCustomIds(List<BoaModule> tree) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
collect(tree, false, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归展平(wantPath=true 取 path;false 取「path 空节点」的 customId)。
|
||||
* 取不出值的节点自然被跳过,子节点照常下钻(锚点可以挂在分组层下面)。
|
||||
*/
|
||||
private static void collect(List<BoaModule> nodes, boolean wantPath, Set<String> out) {
|
||||
private static void collect(List<BoaModule> nodes, Set<String> out) {
|
||||
if (nodes == null) {
|
||||
return;
|
||||
}
|
||||
@@ -56,11 +42,10 @@ public final class FormModuleKeys {
|
||||
continue;
|
||||
}
|
||||
String path = trimToNull(node.getPath());
|
||||
String value = wantPath ? path : (path == null ? trimToNull(node.getCustomId()) : null);
|
||||
if (value != null) {
|
||||
out.add(value);
|
||||
if (path != null) {
|
||||
out.add(path);
|
||||
}
|
||||
collect(node.getChildren(), wantPath, out);
|
||||
collect(node.getChildren(), out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.yunhai.formcenter.controller.api;
|
||||
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.AdminModels;
|
||||
import com.yunhai.formcenter.service.AdminDomainService;
|
||||
import com.yunhai.formcenter.service.FormAdminGuard;
|
||||
import com.yunhai.formcenter.service.FormDefService;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员两表的维护端点(T10 方案 §5.3):校级表单管理员三端点摊 {@code /admin/app}(应用设置行),
|
||||
* 单表单管理员三端点摊 {@code /admin/forms}(表单设计行)。
|
||||
*
|
||||
* <p><b>这两组端点属于维护面,不是数据面</b>:谁能进角色设置页由 UserBase 模块行决定(守卫红线——
|
||||
* 管理员表本身不作入口凭证),加进去的人只升级数据范围({@code DataScopeService})。
|
||||
* 目标域一律走 {@code FormAdminGuard} 的会话域校验:校级只本校、超管可显式指定(含代查任意校),
|
||||
* 名单里的人再逐个复核归属({@code AdminDomainService})。</p>
|
||||
*
|
||||
* <p>返回形态:写操作直接回<b>操作后的全量列表</b>(不分页,每校/每表单个位数),
|
||||
* 前端省一次刷新请求,也就不会出现"删了最后一员、列表还留着那行"的中间态。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/form/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminDomainController {
|
||||
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final FormDefService defService;
|
||||
private final AdminDomainService adminService;
|
||||
|
||||
// ==================== 校级表单管理员 ====================
|
||||
|
||||
/** 全量列表(超管可用 {@code schId} 代查任意校) */
|
||||
@GetMapping("/school-admins")
|
||||
public List<AdminModels.AdminView> listSchoolAdmins(BoaUser user,
|
||||
@RequestParam(required = false) Long schId) {
|
||||
return adminService.listSchoolAdmins(
|
||||
adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
|
||||
}
|
||||
|
||||
@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);
|
||||
return adminService.addSchoolAdmins(domain, body == null ? null : body.getUsers());
|
||||
}
|
||||
|
||||
@DeleteMapping("/school-admins/{id}")
|
||||
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);
|
||||
return adminService.removeSchoolAdmin(domain, id);
|
||||
}
|
||||
|
||||
// ==================== 单表单管理员 ====================
|
||||
|
||||
/** 表单定位走数据面同一条 {@code readDomainForm}:本校 ∪ 平台级可见,他校按"表单不存在"回答 */
|
||||
@GetMapping("/forms/{formUuid}/admins")
|
||||
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);
|
||||
return adminService.listFormAdmins(requireForm(domain, formUuid));
|
||||
}
|
||||
|
||||
@PostMapping("/forms/{formUuid}/admins")
|
||||
public List<AdminModels.AdminView> addFormAdmins(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@PathVariable("formUuid") String formUuid,
|
||||
@RequestBody AdminModels.AdminAddRequest body) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
Form form = requireForm(domain, formUuid);
|
||||
return adminService.addFormAdmins(form, domain, body == null ? null : body.getUsers());
|
||||
}
|
||||
|
||||
@DeleteMapping("/forms/{formUuid}/admins/{id}")
|
||||
public List<AdminModels.AdminView> removeFormAdmin(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@PathVariable("formUuid") String formUuid,
|
||||
@PathVariable("id") Long id) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN);
|
||||
Form form = requireForm(domain, formUuid);
|
||||
return adminService.removeFormAdmin(form, id);
|
||||
}
|
||||
|
||||
private Form requireForm(Long domain, String formUuid) {
|
||||
return defService.readDomainForm(domain, formUuid);
|
||||
}
|
||||
}
|
||||
@@ -156,41 +156,41 @@ public class FormAdminController {
|
||||
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
|
||||
}
|
||||
|
||||
// ==================== 选项字典(契约 §2.1) ====================
|
||||
// ==================== 选项字典(契约 §2.1;T10 方案 §3:字典 API 摊应用设置行 /admin/app 把关) ====================
|
||||
|
||||
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
|
||||
@GetMapping("/dict/list")
|
||||
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
|
||||
return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
|
||||
}
|
||||
|
||||
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
|
||||
@GetMapping("/dict/selectable")
|
||||
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
|
||||
return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
|
||||
return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_APP_SETTINGS));
|
||||
}
|
||||
|
||||
@PostMapping("/dict/save")
|
||||
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
|
||||
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode(), body.getName());
|
||||
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode(), body.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/dict/delete")
|
||||
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
|
||||
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode());
|
||||
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getCode());
|
||||
}
|
||||
|
||||
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
|
||||
@PostMapping("/dict/item/save")
|
||||
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
|
||||
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT),
|
||||
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS),
|
||||
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
|
||||
}
|
||||
|
||||
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
|
||||
@PostMapping("/dict/item/delete")
|
||||
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
|
||||
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getDictCode(), body.getValue());
|
||||
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_APP_SETTINGS), body.getDictCode(), body.getValue());
|
||||
}
|
||||
|
||||
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.yunhai.formcenter.service.AnswerReadService;
|
||||
import com.yunhai.formcenter.service.AnswerStatsService;
|
||||
import com.yunhai.formcenter.service.DataScopeService;
|
||||
import com.yunhai.formcenter.service.FormAdminGuard;
|
||||
import com.yunhai.formcenter.service.FormDefService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
@@ -25,13 +26,14 @@ import java.net.URLEncoder;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。
|
||||
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据面入口判定,T4 四条读出路径的前两条)。
|
||||
*
|
||||
* <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p>
|
||||
* <ul>
|
||||
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data},
|
||||
* 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");</li>
|
||||
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId,
|
||||
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard#requireDataView}:数据管理 {@code /admin/data}
|
||||
* 或统计 {@code /admin/stats} <b>任一</b>,手册 §2.2"统计可并入数据管理,二选一"推广到整个数据面;
|
||||
* 详情按 answerId 寻址、入口时还没有表单对象,故用 {@code requireAdminAny} 取域);</li>
|
||||
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + 管理员两表,T10 方案 §5.1;
|
||||
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li>
|
||||
* </ul>
|
||||
*
|
||||
@@ -49,6 +51,7 @@ import java.util.List;
|
||||
public class FormAnswerReadController {
|
||||
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final FormDefService defService;
|
||||
private final AnswerReadService readService;
|
||||
private final AnswerStatsService statsService;
|
||||
private final AnswerExportService exportService;
|
||||
@@ -59,7 +62,7 @@ public class FormAnswerReadController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody ReadModels.ListRequest body) {
|
||||
requireFormUuid(body.getFormUuid());
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
|
||||
return readService.list(user, domain, body);
|
||||
}
|
||||
|
||||
@@ -68,7 +71,10 @@ public class FormAnswerReadController {
|
||||
public ReadModels.DetailView detail(BoaUser user,
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("answerId") String answerId) {
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
// 入口时只有答卷 uuid、表单还没定位,故按"数据管理或统计任一"取域;
|
||||
// 域内可见性由 readService.detail 里的同一份范围规格 + visible() 行级把关(与列表同集,V27)
|
||||
Long domain = adminGuard.requireAdminAny(user, schId,
|
||||
FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
return readService.detail(user, domain, answerId);
|
||||
}
|
||||
|
||||
@@ -78,7 +84,7 @@ public class FormAnswerReadController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestParam("formUuid") String formUuid) {
|
||||
requireFormUuid(formUuid);
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(formUuid));
|
||||
return readService.filterFields(domain, formUuid);
|
||||
}
|
||||
|
||||
@@ -88,8 +94,7 @@ public class FormAnswerReadController {
|
||||
@RequestParam(required = false) Long schId,
|
||||
@RequestBody ReadModels.QueryRequest body) {
|
||||
requireFormUuid(body.getFormUuid());
|
||||
Long domain = adminGuard.requireAdminAny(user, schId,
|
||||
FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
|
||||
return statsService.stats(user, domain, body);
|
||||
}
|
||||
|
||||
@@ -106,7 +111,7 @@ public class FormAnswerReadController {
|
||||
@RequestBody ReadModels.QueryRequest body,
|
||||
HttpServletResponse response) throws IOException {
|
||||
requireFormUuid(body.getFormUuid());
|
||||
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
|
||||
Long domain = adminGuard.requireDataView(user, schId, defService.findFormForGuard(body.getFormUuid()));
|
||||
String fileName = "form-answers.xlsx";
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.yunhai.formcenter.controller.api;
|
||||
|
||||
import com.yunhai.formcenter.model.SchoolModels;
|
||||
import com.yunhai.formcenter.service.FormAdminGuard;
|
||||
import com.yunhai.formcenter.service.SchoolUserTreeService;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -29,11 +33,15 @@ import java.util.Map;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class FormBaseController {
|
||||
|
||||
/** 本应用 app_code(手册 §1 定死:app_code = name = ctx = 目录 key = jump_app)。 */
|
||||
private static final String APP_CODE = "form-center";
|
||||
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final SchoolUserTreeService schoolUserTreeService;
|
||||
|
||||
/**
|
||||
* appCode 是否指向「别的应用」(= 需要 H4 的目标应用模块树)。缺省/空白/等于本应用都算本应用。
|
||||
* 包级可见:供同包单测锁定判定,不依赖 SDK 静态客户端。
|
||||
@@ -48,6 +56,21 @@ public class FormBaseController {
|
||||
return authUserWithDept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本校组织树(教师版,选人弹窗的数据源;同 notice {@code /api/base/GetSchoolAll} 形态)。
|
||||
*
|
||||
* <p><b>T10 落地的原因</b>:管理员两表的选人入口要"限本校树"(方案 §5.3 末段),而 UserBase 侧
|
||||
* 没有给子应用开放"按 app 过滤的组织树"端点,平台现行做法就是子应用自己代理组树——notice 与
|
||||
* flow-center 各有一份,本端点是同一口径的第三份({@code SchoolUserTreeService})。
|
||||
* 域只从会话推导({@code schId} 不是入参),持应用设置或表单设计任一模块行才能取树。</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);
|
||||
return schoolUserTreeService.teacherTree(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户已授权模块树(左侧菜单)。
|
||||
* <p><b>依赖 UserBase 侧数据</b>:本应用的 {@code app_module} 行({@code path} 须逐字对上
|
||||
@@ -110,8 +133,8 @@ public class FormBaseController {
|
||||
* modId 取值:优先 {@code customId}(数值),以对上 front {@code router/routes.ts} 的
|
||||
* {@code meta.modId} 约定;customId 非数字或缺失时回退主键 id。
|
||||
* 注意:不能直接取主键 id——那会让前端 modId 兜底匹配永久失配(path 匹配仍可用)。
|
||||
* 表单中心的 {@code fc:scope:all}(数据范围权限锚点,契约 §8)是<b>非数字</b> customId,
|
||||
* 且 path 为空、不进导航,故只由后端扫描授权集合消费,不依赖本映射结果。
|
||||
* 中心应用的模块行自 T10(手册 §2.2 v1.5 五行版)起 customId 恒为数字 200~204;非数字时回退主键 id
|
||||
* 只是防御性兜底,不再有"数据范围权限锚点"依赖这条映射(该协议已于 T10 退役,载体换成管理员两表)。
|
||||
*/
|
||||
private static Object resolveModId(BoaModule m) {
|
||||
String customId = m.getCustomId();
|
||||
|
||||
@@ -39,6 +39,8 @@ import javax.persistence.UniqueConstraint;
|
||||
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"),
|
||||
@Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"),
|
||||
@Index(name = "idx_answer_subject", columnList = "subject_id"),
|
||||
// T10 方案 §4③:SCHOOL 范围(本校提交)的谓词列索引
|
||||
@Index(name = "idx_answer_submitter_sch", columnList = "submitter_sch_id"),
|
||||
// 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id)
|
||||
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
|
||||
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
|
||||
@@ -82,6 +84,17 @@ public class Answer extends BaseEntity {
|
||||
@Column(name = "submitter_id", nullable = false, updatable = false)
|
||||
private Integer submitterId;
|
||||
|
||||
/**
|
||||
* 填报人会话学校(T10 方案 §4③,D2=B 的落点):范围值 {@code SCHOOL} 的谓词列,
|
||||
* 提交时从会话 {@code currentRole.schId} 落值、之后不改。
|
||||
*
|
||||
* <p>NULL=未记录(存量行),按"不参与校级范围匹配"处理(fail-closed,方案 §4 存量条款)。
|
||||
* 它与 {@code sch_id} 同源于会话校(校域不变式:见下方建表注记),本列是把"本校提交"
|
||||
* 写成显式谓词,而不是靠租户域隐含它。</p>
|
||||
*/
|
||||
@Column(name = "submitter_sch_id", updatable = false)
|
||||
private Long submitterSchId;
|
||||
|
||||
/** 关于谁(管理员/教师代填时=被代填人;普通用户只能=自己,方案 §6.3) */
|
||||
@Column(name = "subject_id")
|
||||
private Integer subjectId;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.formcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 单表单管理员(T10 / 管理员体系方案 §4②):被指定表单的<b>数据查看 + 导出</b>授权(D3 裁决,
|
||||
* 编辑权仍走 {@code /admin/forms} 模块行,本表不扩"管"权)。
|
||||
*
|
||||
* <p>{@code sch_id} 是冗余的表单租户域(= {@code form.sch_id},基础表单=0),只用于落库时的域复核与
|
||||
* 跨校拒绝;范围解析按 {@code (form_uuid, user_id)} 命中,命中后在基础表单上一律降为
|
||||
* {@code SCHOOL}(跨校学生数据不因授权而可见,D2 裁决)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "form_admin",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_form_admin", columnNames = {"form_uuid", "user_id"}),
|
||||
indexes = @Index(name = "idx_form_admin_user", columnList = "user_id"))
|
||||
public class FormAdmin extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 授权的表单(按 uuid,跨版本不变——契约 §1) */
|
||||
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
|
||||
private String formUuid;
|
||||
|
||||
/** 冗余表单租户域(form.sch_id,基础表单=0) */
|
||||
@Column(name = "sch_id", nullable = false, updatable = false)
|
||||
private Long schId;
|
||||
|
||||
@Column(name = "user_id", nullable = false, updatable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 姓名快照,仅供列表展示 */
|
||||
@Column(name = "user_name", nullable = false, length = 64)
|
||||
private String userName;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.formcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 校级表单管理员(T10 / 管理员体系方案 §4①):数据范围扩展的载体,取代已退役的 UserBase 侧
|
||||
* customId 权限锚点行(方案 §7)。
|
||||
*
|
||||
* <p><b>它只升级数据范围,不是页面入口凭证</b>(方案 §3 三层模型不变):进哪个管理面仍由 UserBase 授权
|
||||
* 模块行判({@code FormAdminGuard}),这张表只让 {@code DataScopeService} 把范围从"仅本人提交"升到
|
||||
* 本校表单=全部、基础表单=本校提交。</p>
|
||||
*
|
||||
* <p>{@code user_name} 是展示快照(列表免回查),权威在 UserBase;教师离校后残留行不产生任何授权
|
||||
* ——解析入口是会话,会话失效即判不到(方案 §8)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "school_admin",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_school_admin", columnNames = {"sch_id", "user_id"}))
|
||||
public class SchoolAdmin extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 租户键(= 会话校;超管代查时=显式指定的目标校) */
|
||||
@Column(name = "sch_id", nullable = false, updatable = false)
|
||||
private Long schId;
|
||||
|
||||
/** UserBase 用户主键({@code BoaUser.id}) */
|
||||
@Column(name = "user_id", nullable = false, updatable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 姓名快照,仅供列表展示 */
|
||||
@Column(name = "user_name", nullable = false, length = 64)
|
||||
private String userName;
|
||||
}
|
||||
@@ -101,17 +101,22 @@ public final class FormEnums {
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围(契约 §8 / 方案 §6.4):<b>闭集四枚举,一期契约即含全部四值</b>——
|
||||
* 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。
|
||||
* 数据范围(契约 §8 / 方案 §6.4):一期契约的四值闭集 + T10 的 {@link #SCHOOL}——
|
||||
* "定了就不变"指既不删改既有值、也不改既有语义,扩展走"加枚举=加实现器"这条既定通道
|
||||
* (管理员体系方案 §5.1)。
|
||||
*
|
||||
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
|
||||
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p>
|
||||
*
|
||||
* <p>{@link #SCHOOL} 与任教班级同一类"枚举里有但不入 {@code selectableScopes()}"(V28 口径):
|
||||
* 它不是筛选器选项,而是管理员两表授权后的<b>解析结果</b>(谓词 {@code submitter_sch_id}=会话学校)。</p>
|
||||
*/
|
||||
public enum DataScope {
|
||||
ALL("全部"),
|
||||
TEACHING_CLASS("任教班级"),
|
||||
SELF_SUBMITTED("仅本人提交"),
|
||||
ABOUT_ME("关于我");
|
||||
ABOUT_ME("关于我"),
|
||||
SCHOOL("本校提交");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yunhai.formcenter.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员两表的出入参模型(T10 方案 §5.3,中心 {@code /api} 面、信封 code=0 族)。
|
||||
*
|
||||
* <p>与契约 §1 的"库内 id 不外露"唯一有出入的地方是 {@link AdminView#getId()}:
|
||||
* 方案 §5.3 的删除端点形态就是 {@code DELETE /…/{id}}(管理员行没有 uuid 可用),
|
||||
* 该 id 只在本人的管理面内出现、且删除前还要按 {@code (id, 会话域)} 复核归属,
|
||||
* 不构成答卷数据面那种可枚举资源标识。取舍记在 T10 报告。</p>
|
||||
*/
|
||||
public final class AdminModels {
|
||||
|
||||
private AdminModels() {
|
||||
}
|
||||
|
||||
/** 选人弹窗提交的一个候选人(userId=UserBase 主键;userName 只做展示快照) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AdminUserItem {
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
}
|
||||
|
||||
/** 批量添加请求体:{@code schId} 只是超管的目标域,校级传别的值即拒(同管理面口径) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AdminAddRequest {
|
||||
private Long schId;
|
||||
private List<AdminUserItem> users;
|
||||
}
|
||||
|
||||
/** 列表行(每校/每表单个位数,一律不分页,方案 §4) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AdminView {
|
||||
private Long id;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,8 @@ public final class ReadModels {
|
||||
private String code;
|
||||
private Set<DataScope> scopes;
|
||||
private Integer userId;
|
||||
/** 会话学校(T10 方案 §5.1:{@code SCHOOL} 范围的谓词基准,其余范围不消费) */
|
||||
private Long viewerSchId;
|
||||
private String status;
|
||||
private List<FormFilterOperators.Spec> filters;
|
||||
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.formcenter.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 本校组织树(选人弹窗数据源)的下发形状(T10 方案 §5.3 末段:同 notice 形态,天然限本校树)。
|
||||
*
|
||||
* <p>字段名对齐前端移植自 notice 的 {@code UserCheckModal.vue}:它按 {@code isParent} 判组/人
|
||||
* (组=文件夹、叶子=人员),按 {@code children} 递归,{@code id/name} 直接进树。</p>
|
||||
*/
|
||||
public final class SchoolModels {
|
||||
|
||||
private SchoolModels() {
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Node {
|
||||
/** 组节点=UserBase 组 id;人节点=UserBase 用户主键(即 admin 两表的 user_id) */
|
||||
private Integer id;
|
||||
private String name;
|
||||
/** true=组(可展开),false=人员叶子 */
|
||||
private Boolean isParent;
|
||||
private List<Node> children = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.formcenter.repository;
|
||||
|
||||
import com.yunhai.formcenter.entity.FormAdmin;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 单表单管理员仓库(T10)。规模=每表单个位数,列表一律不分页(方案 §4)。
|
||||
*
|
||||
* <p>{@code uk_form_admin(form_uuid, user_id)} 是重复添加的幂等兜底;{@code idx_form_admin_user}
|
||||
* 供"我管理的表单"这类按人的反查(v2 入口,本轮不消费,方案 §8 已登记)。</p>
|
||||
*/
|
||||
public interface FormAdminRepository extends JpaRepository<FormAdmin, Long> {
|
||||
|
||||
List<FormAdmin> findByFormUuidOrderByIdAsc(String formUuid);
|
||||
|
||||
Optional<FormAdmin> findByIdAndFormUuid(Long id, String formUuid);
|
||||
|
||||
boolean existsByFormUuidAndUserId(String formUuid, Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.formcenter.repository;
|
||||
|
||||
import com.yunhai.formcenter.entity.SchoolAdmin;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 校级表单管理员仓库(T10)。规模=每校个位数,列表一律不分页(方案 §4)。
|
||||
*
|
||||
* <p>{@code uk_school_admin(sch_id, user_id)} 是重复添加的幂等兜底;行按 id 升序回显,
|
||||
* 与前端"序号/姓名/操作"的列表形态同序。</p>
|
||||
*/
|
||||
public interface SchoolAdminRepository extends JpaRepository<SchoolAdmin, Long> {
|
||||
|
||||
List<SchoolAdmin> findBySchIdOrderByIdAsc(Long schId);
|
||||
|
||||
Optional<SchoolAdmin> findByIdAndSchId(Long id, Long schId);
|
||||
|
||||
boolean existsBySchIdAndUserId(Long schId, Integer userId);
|
||||
|
||||
long countBySchId(Long schId);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public class AboutMeScopeImplementor implements AnswerScopeImplementor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
|
||||
return cb.equal(root.get("subjectId"), userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.entity.FormAdmin;
|
||||
import com.yunhai.formcenter.entity.SchoolAdmin;
|
||||
import com.yunhai.formcenter.model.AdminModels;
|
||||
import com.yunhai.formcenter.repository.FormAdminRepository;
|
||||
import com.yunhai.formcenter.repository.SchoolAdminRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员两表的读写(T10 方案 §4/§5.3):{@link AdminScopeSource} 的生产实现 + 管理面 CRUD。
|
||||
*
|
||||
* <p><b>三条写侧纪律</b>:</p>
|
||||
* <ol>
|
||||
* <li><b>幂等</b>:{@code uk_school_admin(sch_id,user_id)} / {@code uk_form_admin(form_uuid,user_id)}
|
||||
* 是重复添加的兜底——已存在的行直接跳过,不报错也不重复建行(沿登记脚本的幂等哲学);</li>
|
||||
* <li><b>不信任前端</b>:选人弹窗的名单来自 {@code GetSchoolAll()}(天然限本校树),但落库前仍逐个
|
||||
* 走 {@link SubjectDirectory#existsInSchool} 复核归属——传别人的 userId 一律拒(方案 §5.3 末段);</li>
|
||||
* <li><b>防呆</b>:校级管理员删到剩 0 拒绝。没有校级管理员的学校仍能用矩阵默认口径读数据,
|
||||
* 但"删空最后一员"会让此后无人能进角色设置页加人(D6 的自管理闭环断掉),所以宁可按业务规则拒。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>行删除一律按 {@code (id, 本次操作域)} 定位({@code findByIdAndSchId}/{@code findByIdAndFormUuid}),
|
||||
* 跨校/跨表单的 id 探测与"不存在"同一口径回答;{@code user_name} 是展示快照,权威始终在 UserBase。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminDomainService implements AdminScopeSource {
|
||||
|
||||
/** 与 school_admin.user_name / form_admin.user_name 的列宽一致(超长按 PARAM_ERROR 拒,不给 500) */
|
||||
private static final int USER_NAME_MAX = 64;
|
||||
|
||||
private final SchoolAdminRepository schoolAdminRepository;
|
||||
private final FormAdminRepository formAdminRepository;
|
||||
private final SubjectDirectory subjectDirectory;
|
||||
|
||||
// ==================== 只读判定(AdminScopeSource) ====================
|
||||
|
||||
@Override
|
||||
public boolean isSchoolAdmin(Long schId, Integer userId) {
|
||||
return schId != null && userId != null && schoolAdminRepository.existsBySchIdAndUserId(schId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFormAdmin(String formUuid, Integer userId) {
|
||||
if (formUuid == null || formUuid.trim().isEmpty() || userId == null) {
|
||||
return false;
|
||||
}
|
||||
return formAdminRepository.existsByFormUuidAndUserId(formUuid.trim(), userId);
|
||||
}
|
||||
|
||||
// ==================== 校级表单管理员(守 /admin/app) ====================
|
||||
|
||||
public List<AdminModels.AdminView> listSchoolAdmins(Long schId) {
|
||||
List<AdminModels.AdminView> views = new ArrayList<>();
|
||||
for (SchoolAdmin row : schoolAdminRepository.findBySchIdOrderByIdAsc(schId)) {
|
||||
views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/** 批量添加(幂等);返回添加后的全量列表,前端免去第二次请求 */
|
||||
@Transactional
|
||||
public List<AdminModels.AdminView> addSchoolAdmins(Long schId, List<AdminModels.AdminUserItem> users) {
|
||||
for (AdminModels.AdminUserItem item : requireUsers(users)) {
|
||||
requireBelongsToSchool(item, schId);
|
||||
if (schoolAdminRepository.existsBySchIdAndUserId(schId, item.getUserId())) {
|
||||
continue;
|
||||
}
|
||||
SchoolAdmin row = new SchoolAdmin();
|
||||
row.setSchId(schId);
|
||||
row.setUserId(item.getUserId());
|
||||
row.setUserName(item.getUserName());
|
||||
// saveAndFlush:同批里重复的人(前端理论上不会给出,但接口不拒这种形态)也要被 exists 检查看到,
|
||||
// 不能让 uk 撞键把一次正常的幂等添加变成 500
|
||||
schoolAdminRepository.saveAndFlush(row);
|
||||
log.info("[角色设置] 新增校级表单管理员 schId={} userId={} name={}",
|
||||
schId, item.getUserId(), item.getUserName());
|
||||
}
|
||||
return listSchoolAdmins(schId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<AdminModels.AdminView> removeSchoolAdmin(Long schId, Long id) {
|
||||
SchoolAdmin row = schoolAdminRepository.findByIdAndSchId(id, schId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "校级管理员记录不存在:" + id));
|
||||
if (schoolAdminRepository.countBySchId(schId) <= 1) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "本校至少保留一名校级表单管理员,最后一名不可删除");
|
||||
}
|
||||
schoolAdminRepository.delete(row);
|
||||
log.info("[角色设置] 删除校级表单管理员 schId={} userId={} rowId={}", schId, row.getUserId(), row.getId());
|
||||
return listSchoolAdmins(schId);
|
||||
}
|
||||
|
||||
// ==================== 单表单管理员(守 /admin/forms) ====================
|
||||
|
||||
public List<AdminModels.AdminView> listFormAdmins(Form form) {
|
||||
List<AdminModels.AdminView> views = new ArrayList<>();
|
||||
for (FormAdmin row : formAdminRepository.findByFormUuidOrderByIdAsc(form.getUuid())) {
|
||||
views.add(toView(row.getId(), row.getUserId(), row.getUserName()));
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加(幂等)。
|
||||
*
|
||||
* @param domain 本次操作域(守卫返回的租户域)——基础表单的 {@code form.schId=0} 不是可复核的学校,
|
||||
* 归属复核按 <b>域</b> 走:校级操作人=会话校(因此只能把自己学校的人加进基础表单的管理员),
|
||||
* 超管平台态(域=0)在此被 {@link SubjectDirectory} 判为"不属于本校"而拒,
|
||||
* 需要给基础表单授权时显式带 {@code schId=某校}。取舍记在 T10 报告。
|
||||
*/
|
||||
@Transactional
|
||||
public List<AdminModels.AdminView> addFormAdmins(Form form, Long domain,
|
||||
List<AdminModels.AdminUserItem> users) {
|
||||
for (AdminModels.AdminUserItem item : requireUsers(users)) {
|
||||
requireBelongsToSchool(item, domain);
|
||||
if (formAdminRepository.existsByFormUuidAndUserId(form.getUuid(), item.getUserId())) {
|
||||
continue;
|
||||
}
|
||||
FormAdmin row = new FormAdmin();
|
||||
row.setFormUuid(form.getUuid());
|
||||
row.setSchId(form.getSchId());
|
||||
row.setUserId(item.getUserId());
|
||||
row.setUserName(item.getUserName());
|
||||
formAdminRepository.saveAndFlush(row);
|
||||
log.info("[表单管理员] 新增 form={} userId={} name={}",
|
||||
form.getCode(), item.getUserId(), item.getUserName());
|
||||
}
|
||||
return listFormAdmins(form);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<AdminModels.AdminView> removeFormAdmin(Form form, Long id) {
|
||||
FormAdmin row = formAdminRepository.findByIdAndFormUuid(id, form.getUuid())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "表单管理员记录不存在:" + id));
|
||||
formAdminRepository.delete(row);
|
||||
log.info("[表单管理员] 删除 form={} userId={}", form.getCode(), row.getUserId());
|
||||
return listFormAdmins(form);
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
/** 请求形态校验:非空名单 + 每人有 userId、有姓名快照(列宽内) */
|
||||
private List<AdminModels.AdminUserItem> requireUsers(List<AdminModels.AdminUserItem> users) {
|
||||
if (users == null || users.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一人(users 为空)");
|
||||
}
|
||||
for (AdminModels.AdminUserItem item : users) {
|
||||
if (item == null || item.getUserId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少 userId");
|
||||
}
|
||||
String name = item.getUserName() == null ? null : item.getUserName().trim();
|
||||
if (name == null || name.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "选人结果缺少姓名:userId=" + item.getUserId());
|
||||
}
|
||||
if (name.length() > USER_NAME_MAX) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"姓名快照过长(≤" + USER_NAME_MAX + "):userId=" + item.getUserId());
|
||||
}
|
||||
item.setUserName(name);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
/** 落库前的归属复核(方案 §5.3:不信任前端,跨校传参一律拒) */
|
||||
private void requireBelongsToSchool(AdminModels.AdminUserItem item, Long schId) {
|
||||
if (!subjectDirectory.existsInSchool(item.getUserId(), schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"该用户不属于本次管理域,不能加为管理员:userId=" + item.getUserId() + " schId=" + schId);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdminModels.AdminView toView(Long id, Integer userId, String userName) {
|
||||
AdminModels.AdminView view = new AdminModels.AdminView();
|
||||
view.setId(id);
|
||||
view.setUserId(userId);
|
||||
view.setUserName(userName);
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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} 都只消费这两个布尔判定,
|
||||
* 加宽一侧(比如将来按人反查"我管理的表单",方案 §8 的 v2 入口)不该把仓库与写侧逻辑渗进解析器;
|
||||
* 同 {@link ModuleTreeSource}/{@link SubjectDirectory} 的先例,本地单测注入内存桩即可断言规则本身。</p>
|
||||
*
|
||||
* <p><b>口径</b>:判定只认会话身份({@code (schId, userId)} / {@code (formUuid, userId)}),
|
||||
* 查不到即 false(fail-closed)——残留行(教师离校)因会话失效而永远走不到这里,方案 §8。</p>
|
||||
*/
|
||||
public interface AdminScopeSource {
|
||||
|
||||
/** 该用户是否 {@code schId} 的校级表单管理员 */
|
||||
boolean isSchoolAdmin(Long schId, Integer userId);
|
||||
|
||||
/** 该用户是否这张表单(按 uuid)的单表单管理员 */
|
||||
boolean isFormAdmin(String formUuid, Integer userId);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */
|
||||
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或管理员两表授权升级)。 */
|
||||
@Component
|
||||
public class AllScopeImplementor implements AnswerScopeImplementor {
|
||||
|
||||
@@ -23,7 +23,7 @@ public class AllScopeImplementor implements AnswerScopeImplementor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ public class AnswerReadService {
|
||||
/**
|
||||
* 会话身份 + 表单 + 请求 → 读路径查询规格。
|
||||
*
|
||||
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来
|
||||
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + 管理员两表解析,解析不出来
|
||||
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的
|
||||
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
|
||||
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
|
||||
@@ -84,6 +84,8 @@ public class AnswerReadService {
|
||||
query.setCode(form.getCode());
|
||||
query.setScopes(scopeService.resolve(user, form));
|
||||
query.setUserId(user == null ? null : user.getId());
|
||||
// T10 方案 §5.1:SCHOOL 范围的谓词基准=会话学校(不是查询域,两者同源于会话校但语义不同)
|
||||
query.setViewerSchId(user == null ? null : sessionSchId(user));
|
||||
query.setStatus(req == null ? null : trimToNull(req.getStatus()));
|
||||
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
|
||||
return query;
|
||||
|
||||
@@ -31,8 +31,10 @@ public interface AnswerScopeImplementor {
|
||||
/**
|
||||
* 产出该范围对单条答卷的行级谓词。
|
||||
*
|
||||
* @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
|
||||
* @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
|
||||
* @param viewerSchId 会话学校({@code submitter_sch_id} 的比对基准,T10 方案 §5.1;
|
||||
* 只有 {@code SCHOOL} 消费它,其余范围按各自基准忽略即可)
|
||||
* @return 恒真({@code cb.conjunction()})=该范围不额外收窄行
|
||||
*/
|
||||
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId);
|
||||
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ public class AnswerScopeQueryBuilder {
|
||||
copy.setCode(q.getCode());
|
||||
copy.setScopes(q.getScopes());
|
||||
copy.setUserId(q.getUserId());
|
||||
copy.setViewerSchId(q.getViewerSchId());
|
||||
copy.setStatus(q.getStatus());
|
||||
copy.setFilters(q.getFilters());
|
||||
copy.setCursorId(q.getCursorId());
|
||||
@@ -234,7 +235,8 @@ public class AnswerScopeQueryBuilder {
|
||||
}
|
||||
List<Predicate> ors = new ArrayList<>();
|
||||
for (DataScope scope : scopes) {
|
||||
ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q.getUserId()));
|
||||
ors.add(scopeService.implementorOf(scope)
|
||||
.rowPredicate(cb, root, q.getUserId(), q.getViewerSchId()));
|
||||
}
|
||||
return cb.or(ors.toArray(new Predicate[0]));
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ public class AnswerService {
|
||||
answer.setCode(form.getCode());
|
||||
answer.setVersion(published.versionRow().getVersion());
|
||||
answer.setSubmitterId(submitterId);
|
||||
// T10 方案 §4③(D2=B):SCHOOL 范围的谓词列=操作人会话学校,与 sch_id 同值由校域不变式保证
|
||||
// (P33 已把代填的 subject 钉在本校内);落值后不随改(updatable=false)
|
||||
answer.setSubmitterSchId(schId);
|
||||
answer.setSubjectId(subjectId);
|
||||
answer.setStatus(FormStatusDefs.defaultStatusCode(form.getStatusDefs()));
|
||||
answer.setValuesJson(valuesJson);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.common.FormModuleKeys;
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
@@ -19,12 +18,17 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围,
|
||||
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
|
||||
* 数据范围解析器(契约 §8 / 方案 §6.4 / 管理员体系方案 §5.1):把"会话用户 + 这张表单"解析成一组
|
||||
* 可见范围,交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
|
||||
*
|
||||
* <p><b>解析规则(优先级)</b>:{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵},
|
||||
* <p><b>解析规则(优先级)</b>:{@code 范围 = 管理员两表授权 ∪ 身份默认矩阵},
|
||||
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
|
||||
*
|
||||
* <p><b>管理员两表来源</b>(T10 起,取代已退役的 {@code customId} 权限锚点行):
|
||||
* {@code school_admin} 命中会话校、或 {@code form_admin} 命中这张表单时,按表单归属域升级——
|
||||
* 本校表单=<b>全部</b>(含本校快应用表单,{@code app_code} 不限),基础表单({@code sch_id=0})=
|
||||
* <b>本校提交</b>(D2 裁决:跨校学生数据不因授权而可见)。他校表单不给任何升级。</p>
|
||||
*
|
||||
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
|
||||
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
|
||||
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
|
||||
@@ -39,18 +43,16 @@ import java.util.Set;
|
||||
* 4/6/5 仅本人提交 仅本人提交
|
||||
* </pre>
|
||||
*
|
||||
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(授权模块行),
|
||||
* 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求),
|
||||
* 但一个是 path、一个是 customId,不要混用。</p>
|
||||
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(UserBase 授权模块行,
|
||||
* 外加 T10 的基础表单读开口),本类答"进来后能看哪些行"。<b>两条线的事实源不同</b>——模块行来自 UserBase
|
||||
* ({@link ModuleTreeSource}),管理员身份来自本库两表({@link AdminScopeSource}),管理员表不充当页面
|
||||
* 入口凭证(方案 §3 三层模型不变)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DataScopeService {
|
||||
|
||||
/** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */
|
||||
public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all";
|
||||
|
||||
/** 超管用户类型(同 FormAdminGuard:不是 roleId,是 UserBase 的用户类型) */
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
|
||||
@@ -59,13 +61,14 @@ public class DataScopeService {
|
||||
private static final int ROLE_STUDENT = 3;
|
||||
private static final int ROLE_ADMIN = 7;
|
||||
|
||||
private final ModuleTreeSource moduleTreeSource;
|
||||
private final List<AnswerScopeImplementor> implementors;
|
||||
private final AdminScopeSource adminScopeSource;
|
||||
|
||||
/**
|
||||
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
|
||||
*
|
||||
* @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理
|
||||
* @param form 目标表单({@code schId} 决定两表升级成哪一档、{@code audience} 决定用矩阵哪一列);
|
||||
* null 按面向学生列解析,且不产生两表升级(无表单域可判)
|
||||
*/
|
||||
public Set<DataScope> resolve(BoaUser user, Form form) {
|
||||
if (user == null || user.getId() == null) {
|
||||
@@ -75,9 +78,10 @@ public class DataScopeService {
|
||||
return EnumSet.of(DataScope.ALL);
|
||||
}
|
||||
Set<DataScope> scopes = new LinkedHashSet<>();
|
||||
// 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认)
|
||||
if (hasScopeAllAnchor(user)) {
|
||||
scopes.add(DataScope.ALL);
|
||||
// 1) 管理员两表授权(契约 §8 v1.5 优先级:管理员来源授权 > 身份默认)
|
||||
DataScope granted = grantedByAdminTables(user, form);
|
||||
if (granted != null) {
|
||||
scopes.add(granted);
|
||||
}
|
||||
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
|
||||
int audience = form == null || form.getAudience() == null
|
||||
@@ -96,8 +100,8 @@ public class DataScopeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里,
|
||||
* 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。
|
||||
* 一期可选的范围值(V28 的断言载体):{@code 任教班级} 的实现器 {@code selectable()=false},
|
||||
* T10 新增的 {@code SCHOOL} 同样 false——它不是筛选器选项,是授权结果(方案 §5.1 明示)。
|
||||
*/
|
||||
public Set<DataScope> selectableScopes() {
|
||||
Set<DataScope> result = new LinkedHashSet<>();
|
||||
@@ -119,9 +123,34 @@ public class DataScopeService {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
|
||||
}
|
||||
|
||||
/** 是否命中 fc:scope:all 权限锚点(只认 path 空节点,P47;见 FormModuleKeys#anchorCustomIds) */
|
||||
private boolean hasScopeAllAnchor(BoaUser user) {
|
||||
return FormModuleKeys.anchorCustomIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
|
||||
/**
|
||||
* 管理员两表在这张表单上应得的范围值(不给=null,交给矩阵)。
|
||||
*
|
||||
* <p>两张表的映射口径<em>逐字相同</em>(方案 §5.1 伪码里两段 if 是同一段逻辑),所以合成一次判定;
|
||||
* 若将来两表要分档(比如单表单管理员只给到本校提交),在这里分叉而不是在调用侧。</p>
|
||||
*/
|
||||
private DataScope grantedByAdminTables(BoaUser user, Form form) {
|
||||
if (form == null || form.getSchId() == null) {
|
||||
return null;
|
||||
}
|
||||
Long ownSch = sessionSchId(user);
|
||||
if (ownSch == null) {
|
||||
// 会话给不出学校:既没有"本校表单"可认,SCHOOL 也没有比对基准——不给任何升级
|
||||
return null;
|
||||
}
|
||||
boolean schoolAdmin = adminScopeSource.isSchoolAdmin(ownSch, user.getId());
|
||||
if (!schoolAdmin && !adminScopeSource.isFormAdmin(form.getUuid(), user.getId())) {
|
||||
return null;
|
||||
}
|
||||
long formSch = form.getSchId();
|
||||
if (formSch == ownSch) {
|
||||
return DataScope.ALL;
|
||||
}
|
||||
if (formSch == Form.PLATFORM_SCH_ID) {
|
||||
return DataScope.SCHOOL;
|
||||
}
|
||||
// 他校表单:两表都不覆盖(校级管理员的授权域=会话校,跨校不成立)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,4 +192,10 @@ public class DataScopeService {
|
||||
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
|
||||
return DataScope.SELF_SUBMITTED;
|
||||
}
|
||||
|
||||
/** 会话校(Integer→Long 换算;无当前身份或无学校归属=null,两表升级不参与) */
|
||||
private static Long sessionSchId(BoaUser user) {
|
||||
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
|
||||
return schId == null ? null : Long.valueOf(schId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,18 @@ import java.util.Set;
|
||||
* 因此不存在"菜单看得见、接口调不动"的两套口径):</p>
|
||||
* <ul>
|
||||
* <li>定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
|
||||
* 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。</li>
|
||||
* 应用设置={@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><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + customId 决定
|
||||
* <p><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + 管理员两表决定
|
||||
* ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
|
||||
* 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。</p>
|
||||
* 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。
|
||||
* <b>管理员两表不充当页面入口凭证</b>(T10 任务书 §0-4 守卫红线):{@link #requireDataView} 里
|
||||
* 唯一的两表消费点是"这条数据的域该给多大"的兜底判断,不新增"进了表才能进页面"的口径。</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -41,8 +44,11 @@ public class FormAdminGuard {
|
||||
public static final String MODULE_DATA_MANAGE = "/admin/data";
|
||||
/** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */
|
||||
public static final String MODULE_STATS = "/admin/stats";
|
||||
/** 字典管理模块行 path */
|
||||
public static final String MODULE_DICT = "/admin/dicts";
|
||||
/**
|
||||
* 应用设置模块行 path(T10 方案 §3,取代原字典管理行 {@code /admin/dicts}):
|
||||
* 页内两个 tab(角色设置/字典管理)共用这一行,字典 API 也摊它把关。
|
||||
*/
|
||||
public static final String MODULE_APP_SETTINGS = "/admin/app";
|
||||
|
||||
private final ModuleTreeSource moduleTreeSource;
|
||||
|
||||
@@ -136,6 +142,46 @@ public class FormAdminGuard {
|
||||
"无该模块权限(需任一):" + String.join(" 或 ", modulePaths));
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面<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 (isSuperAdmin(user)) {
|
||||
// 超管的域语义原样交给 requireAdmin:显式目标域(含 0=平台级)优先,不在这里换默认值
|
||||
return requireAdmin(user, targetSchId, MODULE_DATA_MANAGE);
|
||||
}
|
||||
if (form == null || form.getSchId() == null) {
|
||||
// 取不到表单与"不是你域内的表单"同一口径:存在性本身就是探测面(契约 §1 uuid 不可枚举)
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在");
|
||||
}
|
||||
Long current = currentSchId(user);
|
||||
if (current == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定查询域");
|
||||
}
|
||||
long formSch = form.getSchId();
|
||||
if (formSch != current && formSch != Form.PLATFORM_SCH_ID) {
|
||||
// 他校表单:按"不存在"回答(同 FormDefService#readDomainForm 的拒绝口径)
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + form.getUuid());
|
||||
}
|
||||
return requireAdminAny(user, targetSchId, MODULE_STATS, MODULE_DATA_MANAGE);
|
||||
}
|
||||
|
||||
/** 会话用户被授权的模块行 path 集合(数据范围解析与调试取证用) */
|
||||
public Set<String> authorizedPaths(BoaUser user) {
|
||||
return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user));
|
||||
|
||||
@@ -229,6 +229,18 @@ public class FormDefService {
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面读接口<b>入口把关</b>用的表单定位(T10 方案 §5.2):只按 uuid 取头表、不做域判定,
|
||||
* 取不到返回 null。域与模块行的判断全在 {@code FormAdminGuard#requireDataView}——
|
||||
* 那里才知道"他校表单与不存在的表单要用同一口径回答",本方法刻意不给拒绝话术。
|
||||
*/
|
||||
public Form findFormForGuard(String formUuid) {
|
||||
if (formUuid == null || formUuid.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return formRepository.findByUuid(formUuid.trim()).orElse(null);
|
||||
}
|
||||
|
||||
// ==================== 写:草稿/发布/生命周期 ====================
|
||||
|
||||
/** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
|
||||
|
||||
@@ -8,12 +8,12 @@ import java.util.List;
|
||||
/**
|
||||
* 会话用户在本应用下的<b>已授权模块树</b>来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
|
||||
*
|
||||
* <p>T4 起两类判断都从这棵树出,<b>共用同一个接口</b>(执行手册 §5 P21/P30 裁决落地口径):</p>
|
||||
* <ul>
|
||||
* <li><b>管理权</b>:定义管理/数据管理/统计/字典管理=被授权对应模块行(path 命中,手册 §2.2 表);</li>
|
||||
* <li><b>数据范围</b>:{@code fc:scope:all} 授权锚点=这棵树里 path 空、customId 为该值的纯权限位
|
||||
* (契约 §8 扩展协议)。</li>
|
||||
* </ul>
|
||||
* <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 装配的出站链负责);
|
||||
@@ -24,6 +24,6 @@ import java.util.List;
|
||||
*/
|
||||
public interface ModuleTreeSource {
|
||||
|
||||
/** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */
|
||||
/** 该用户在表单中心应用下的已授权模块树(平台按应用原样下发,本侧不裁剪);无授权或不可达=空列表 */
|
||||
List<BoaModule> authorizedModules(BoaUser user);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Answer;
|
||||
import com.yunhai.formcenter.enums.FormEnums.DataScope;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
|
||||
/**
|
||||
* 范围实现器:<b>本校提交</b>({@code submitter_sch_id}=会话学校,T10 方案 §5.1 / 契约 §8 v1.5)。
|
||||
*
|
||||
* <p>它是管理员两表在<b>基础表单(sch_id=0)</b>上的落地形态:校级/单表单管理员都不因授权而看见
|
||||
* 他校学生数据(D2=B 裁决加列的动因)。给不出会话学校时明确拒绝,不静默降成"无学校条件"——
|
||||
* 那等于把本校范围读成全平台。</p>
|
||||
*
|
||||
* <p>{@link #selectable()} 为 false 的理由与任教班级同族但相反:任教班级是"能力未开放",
|
||||
* 本值是"能力已开放但不是一个筛选选项"——它是授权结果,出现在筛选器里等于让调用方自己声明
|
||||
* 一个未经授权的范围(V28 断言的就是这个"不入 selectableScopes")。</p>
|
||||
*/
|
||||
@Component
|
||||
public class SchoolScopeImplementor implements AnswerScopeImplementor {
|
||||
|
||||
@Override
|
||||
public DataScope scope() {
|
||||
return DataScope.SCHOOL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean selectable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
|
||||
if (viewerSchId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "数据范围 本校提交 需要会话学校身份");
|
||||
}
|
||||
// NULL 存量行天然不匹配等值谓词(fail-closed,方案 §4 存量条款),不需要额外的 is null 分支
|
||||
return cb.equal(root.get("submitterSchId"), viewerSchId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.model.SchoolModels;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
|
||||
import com.yunhai.platform.openapi.clazz.YschClassOpenApi;
|
||||
import com.yunhai.platform.openapi.clazz.model.SchTea;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 本校组织树(选人弹窗的数据源,T10 方案 §5.3 末段"同 notice 形态,天然限本校树")。
|
||||
*
|
||||
* <p><b>口径抄的是平台现行实现</b>:组树只取骨架并整棵剔除学生组({@code type=8}),成员来自 userclass
|
||||
* 的教师列表({@code YschClassOpenApi.getSchTeaList})按所属组挂载——notice 的 {@code GetSchoolAll} 与
|
||||
* flow-center 的 {@code /admin/user/teacherTree} 都是这一条。不走 {@code getGroupUsers}:那条会把
|
||||
* 管理员等非教师账号混进树(flow-center 注释里写明了这是"选人数据不对"的根因)。</p>
|
||||
*
|
||||
* <p><b>教师列表拉不到时只回组骨架并打 warn</b>(同上先例):空组会被剪掉,前端看到的是"只有学校根节点"
|
||||
* 的空树,比抛 500 更能在实拍里被发现;但组树本身取不到是硬失败(域都给不出,不能假装没人)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SchoolUserTreeService {
|
||||
|
||||
/** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP)——管理员候选只可能是教职工 */
|
||||
private static final int GROUP_TYPE_STUDENT = 8;
|
||||
|
||||
private final OpenApiGroupClient groupClient;
|
||||
|
||||
/** 教师版组织树(根=学校组,叶子=教师);{@code schId} 只从会话域来,不接受外部传入 */
|
||||
public List<SchoolModels.Node> teacherTree(Long schId) {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能取本校组织树");
|
||||
}
|
||||
BoaGroup subtree;
|
||||
try {
|
||||
subtree = groupClient.getGroupById(schId.intValue(), true);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
|
||||
}
|
||||
if (subtree == null || subtree.getId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
|
||||
}
|
||||
|
||||
Map<Integer, SchoolModels.Node> groups = new LinkedHashMap<>();
|
||||
List<BoaGroup> flat = new ArrayList<>();
|
||||
flattenGroups(subtree, flat);
|
||||
for (BoaGroup group : flat) {
|
||||
SchoolModels.Node node = new SchoolModels.Node();
|
||||
node.setId(group.getId());
|
||||
node.setName(group.getName() == null ? "" : group.getName());
|
||||
node.setIsParent(true);
|
||||
groups.put(group.getId(), node);
|
||||
}
|
||||
attachTeachers(groups, schId);
|
||||
for (BoaGroup group : flat) {
|
||||
for (BoaGroup child : group.getChildren() == null
|
||||
? Collections.<BoaGroup>emptyList() : group.getChildren()) {
|
||||
SchoolModels.Node childNode = child == null ? null : groups.get(child.getId());
|
||||
SchoolModels.Node parent = groups.get(group.getId());
|
||||
if (childNode != null && parent != null) {
|
||||
parent.getChildren().add(childNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
SchoolModels.Node root = groups.get(schId.intValue());
|
||||
if (root == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
pruneEmptyGroups(root);
|
||||
return Collections.singletonList(root);
|
||||
}
|
||||
|
||||
/** 教师按所属组挂叶子;归属组全被剔除(如只在学生组)时兜底挂学校根 */
|
||||
private void attachTeachers(Map<Integer, SchoolModels.Node> groups, Long schId) {
|
||||
List<SchTea> teachers;
|
||||
try {
|
||||
teachers = YschClassOpenApi.getSchTeaList(schId.intValue());
|
||||
} catch (Exception e) {
|
||||
log.warn("[选人树] 教师列表拉取失败,本轮只回组骨架 schId={}:{}", schId, e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (teachers == null) {
|
||||
return;
|
||||
}
|
||||
for (SchTea tea : teachers) {
|
||||
if (tea == null || tea.getUserTeaSerId() == null) {
|
||||
continue;
|
||||
}
|
||||
SchoolModels.Node leaf = new SchoolModels.Node();
|
||||
leaf.setId(tea.getUserTeaSerId());
|
||||
leaf.setName(tea.getTeaName() == null ? "" : tea.getTeaName());
|
||||
leaf.setIsParent(false);
|
||||
List<Integer> groupIds = tea.getGroupIds();
|
||||
if ((groupIds == null || groupIds.isEmpty()) && tea.getGroupId() != null) {
|
||||
groupIds = Collections.singletonList(tea.getGroupId());
|
||||
}
|
||||
boolean attached = false;
|
||||
if (groupIds != null) {
|
||||
for (Integer groupId : groupIds) {
|
||||
SchoolModels.Node group = groups.get(groupId);
|
||||
if (group != null) {
|
||||
group.getChildren().add(leaf);
|
||||
attached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!attached) {
|
||||
SchoolModels.Node root = groups.get(schId.intValue());
|
||||
if (root != null) {
|
||||
root.getChildren().add(leaf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 组子树扁平化;学生组(type=8)整棵剔除(含其后代) */
|
||||
private void flattenGroups(BoaGroup group, List<BoaGroup> out) {
|
||||
if (group == null || group.getId() == null) {
|
||||
return;
|
||||
}
|
||||
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
|
||||
return;
|
||||
}
|
||||
out.add(group);
|
||||
if (group.getChildren() != null) {
|
||||
for (BoaGroup child : group.getChildren()) {
|
||||
flattenGroups(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 剪掉没有任何教师的空组(否则选人树里全是点不动的文件夹)。返回该子树里是否还有人员叶子;
|
||||
* 学校根节点由调用方无条件保留,所以"本校一个教师都没拉到"时前端看到的是一棵只有根的空树。
|
||||
*/
|
||||
private boolean pruneEmptyGroups(SchoolModels.Node node) {
|
||||
if (!Boolean.TRUE.equals(node.getIsParent())) {
|
||||
return true;
|
||||
}
|
||||
List<SchoolModels.Node> kept = new ArrayList<>();
|
||||
for (SchoolModels.Node child : node.getChildren()) {
|
||||
if (pruneEmptyGroups(child)) {
|
||||
kept.add(child);
|
||||
}
|
||||
}
|
||||
node.setChildren(kept);
|
||||
return !kept.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
|
||||
return cb.equal(root.get("submitterId"), userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
|
||||
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId, Long viewerSchId) {
|
||||
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
|
||||
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 表单中心服务域(T2 起填充):定义域(发布/升版/复制/字典/schema 校验器)、
|
||||
* 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + fc:scope:all)、
|
||||
* 答卷域(提交幂等/限次/截止/软删留痕)、数据范围解析(契约 §8 矩阵 + 管理员两表)、
|
||||
* 检索统计与导出、上传模块(FILE_PATH/FILE_WEB_PATH,值只存 {id,fileName,key})。
|
||||
*/
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
Reference in New Issue
Block a user