feat(t5-shell): T5 前端壳执行报告 + service 两处验收遗留小改
build / build-push (push) Successful in 2m19s

- 勘误③真落:docs/T3-report.md §2 V32「两个不同教师」补注(实为两个 roleId=7 操作者,教师(1) 按 P21 无代填权)
- P47 收紧:FormModuleKeys.anchorCustomIds 只认 path 空节点的 customId,DataScopeService.hasScopeAllAnchor 切过去;补「带 path 的导航行误配 fc:scope:all 不升级范围」用例(mvn -o clean package 178 例全绿)
- docs/T5-report.md:T5 做了什么 / 逐条自验证据 / 问题单 P48~P53 / 两仓推送与 CI
This commit is contained in:
chenxing
2026-10-14 20:36:31 +08:00
parent c0a0df47c1
commit 2c68d6b9f4
6 changed files with 163 additions and 8 deletions
@@ -30,13 +30,23 @@ public final class FormModuleKeys {
return result;
}
/** 授权 customId 集合(path 空的权限锚点主要靠它表达) */
public static Set<String> customIds(List<BoaModule> tree) {
/**
* 权限锚点的 customId 集合:<b>只收 path 为空的节点</b>(契约 §8「锚点行形态」注记 / P47)。
*
* <p>数据范围升级位({@code fc:scope:all})按契约只应挂在<b>不进导航的 path 空行</b>上,
* 扫描必须按这个形态收:带 path 的导航行若误配了该值,不该把看数据的人悄悄升级成"全校全部"
* ——宽方向的误配同样是不受控的授权膨胀。</p>
*/
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) {
if (nodes == null) {
return;
@@ -45,11 +55,16 @@ public final class FormModuleKeys {
if (node == null) {
continue;
}
String value = wantPath ? node.getPath() : node.getCustomId();
if (value != null && !value.trim().isEmpty()) {
out.add(value.trim());
String path = trimToNull(node.getPath());
String value = wantPath ? path : (path == null ? trimToNull(node.getCustomId()) : null);
if (value != null) {
out.add(value);
}
collect(node.getChildren(), wantPath, out);
}
}
private static String trimToNull(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
}
@@ -119,9 +119,9 @@ public class DataScopeService {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
}
/** 是否命中 fc:scope:all 权限锚点(path 空的模块行,只由后端扫描授权集合消费,不进导航) */
/** 是否命中 fc:scope:all 权限锚点(只认 path 空节点,P47;见 FormModuleKeys#anchorCustomIds) */
private boolean hasScopeAllAnchor(BoaUser user) {
return FormModuleKeys.customIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
return FormModuleKeys.anchorCustomIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
}
/**
@@ -130,6 +130,25 @@ class DataScopeServiceTest {
.containsExactly(DataScope.SELF_SUBMITTED);
}
@Test
@DisplayName("P47:锚点扫描只认 path 空行——带 path 的导航行误配 fc:scope:all 不升级范围")
void customIdOnNavRowDoesNotUpgradeScope() {
BoaUser teacher = user(1);
// 误配形态:一行既有页面 path(会被渲染成菜单),又挂了锚点 customId
InMemoryModuleTreeSource misconfigured = new InMemoryModuleTreeSource();
misconfigured.grantPathWithCustomId(teacher.getId(), "/admin/data",
DataScopeService.SCOPE_ALL_CUSTOM_ID);
assertThat(serviceWith(misconfigured).resolve(teacher, form(STUDENT_FACING)))
.as("契约 §8 的锚点行形态是「path 空 + customId」,导航行上的误配值不参与范围升级")
.containsExactly(DataScope.SELF_SUBMITTED);
// 对照:同一用户同一值,改挂到 path 空行即升级——证明失败面只在"行形态",不是扫描整体失效
misconfigured.grantCustomId(teacher.getId(), DataScopeService.SCOPE_ALL_CUSTOM_ID);
assertThat(serviceWith(misconfigured).resolve(teacher, form(STUDENT_FACING)))
.containsExactly(DataScope.ALL);
}
// ==================== V28:任教班级在枚举里但一期不可选 ====================
@Test
@@ -34,6 +34,14 @@ public class InMemoryModuleTreeSource implements ModuleTreeSource {
rows(userId).add(row(null, customId));
}
/**
* 误配形态(P47 取证用):一条<em>会进导航</em>的页面路由行同时挂了 customId。
* 契约 §8 的锚点行形态是「path 空 + customId」,这种行不该被当成锚点消费。
*/
public void grantPathWithCustomId(Integer userId, String path, String customId) {
rows(userId).add(row(path, customId));
}
/** 清空某人的授权(构造"角色是管理员但没被授权模块行"的反例) */
public void clear(Integer userId) {
byUser.remove(userId);