Compare commits

..

3 Commits

Author SHA1 Message Date
huangzd1997 51c5fe1129 fix(任务中断): 已终态任务也幂等刷新缓存/快照,修历史遗留的处理中卡死
第一次修复只在 DB 刚被改成 FAILED 时同步缓存;对 DB 已是终态、但 Redis 里
仍缓存 RUNNING 的历史任务不生效(真机:28094 反复调中断仍返回 progress=RUNNING)。
现在无论是否刚更新 DB,都按 DB 现值同步模块缓存与进度快照,可自愈。
2026-09-13 15:46:18 +08:00
huangzd1997 3d208ea0e5 fix(任务中断): 标失败时同步模块缓存与进度快照,修"处理中"卡死
客户端重启上报中断只改了 file_task,模块缓存/进度快照仍是 RUNNING,
导致 progress/batch 继续回报 RUNNING:前端任务面板永远"处理中"并阻塞该工具后续任务
(2026-09-13 真机:店铺数据采集 28094 history=FAILED 但 progress=RUNNING,界面卡住)。
markInterrupted 现在同时刷新模块缓存(saveFileTaskCache)与进度快照(新增 markTerminal)。
2026-09-13 15:16:14 +08:00
huangzd1997 a4f60ef21c fix(权限): 用户菜单权限“自己没掉”——分区落库时级联清理跨类型误删 + 编辑弹窗空授权提交
- 级联清理的有效集改为按目标「完整直接授权」计算(读库,含 admin/app 全类型)。
  此前 admin/app 分区落库把本次提交的 id 当成完整有效集,员工持有的另一类型授权
  会整体被判越权删除;两次分区落库互相补刀,最终清空员工全部菜单权限
  (生产表现:保存某非超管的权限后,他直建员工的菜单隔三差五自己消失)
- AdminUserService 改为两类落库完成后再统一级联一次,新增 cascadeSubordinateOverreach 入口
- 后台「编辑用户」弹窗:授权未加载完成/失败时禁用保存并明确提示,
  避免以空 columnIds 整树清空该用户授权(改动密码等操作也会连带触发)
- 回归测试:deferredCascadeKeepsSubordinateGrantsOfOtherMenuType、
  perTypeReplacementDoesNotCascadeBeforeAllTypesWritten、edit_user_dialog_blocks_save_until_auth_loaded

已知既有红测试(与本次改动无关,干净 HEAD 上同样复现,未新增):
ArchitectureBoundaryTest.taskToBusinessDependencyDoesNotGrow(110 > 基线 84)、
HttpClientTimeoutEffectiveTest.connectTimeoutFiresOnUnreachableHost(本机网络环境 60s)
2026-09-13 14:18:01 +08:00
8 changed files with 218 additions and 11 deletions
@@ -15,6 +15,8 @@ const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role:
const errors = reactive<EditUserFormErrors>({}) const errors = reactive<EditUserFormErrors>({})
const busy = ref(false) const busy = ref(false)
const loadingAuth = ref(false) const loadingAuth = ref(false)
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
const authReady = ref(false)
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -28,6 +30,7 @@ watch(
form.columnIds = [] form.columnIds = []
form.originalRole = base.originalRole form.originalRole = base.originalRole
errors.password = undefined errors.password = undefined
authReady.value = false
void refreshAuth() void refreshAuth()
}, },
) )
@@ -38,7 +41,10 @@ async function refreshAuth(): Promise<void> {
try { try {
const { checkedIds } = await loadUserMenuAuth(props.user.id) const { checkedIds } = await loadUserMenuAuth(props.user.id)
form.columnIds = checkedIds form.columnIds = checkedIds
authReady.value = true
} catch (error) { } catch (error) {
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
authReady.value = false
showAdminFeedback(actionableErrorText(error), 'error') showAdminFeedback(actionableErrorText(error), 'error')
} finally { } finally {
loadingAuth.value = false loadingAuth.value = false
@@ -50,6 +56,10 @@ function close(): void {
} }
async function save(): Promise<void> { async function save(): Promise<void> {
if (!authReady.value) {
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
return
}
const { valid, errors: errs } = validateEditUserForm(form) const { valid, errors: errs } = validateEditUserForm(form)
Object.assign(errors, errs) Object.assign(errors, errs)
if (!valid) return if (!valid) return
@@ -103,12 +113,15 @@ async function save(): Promise<void> {
<div class="auth-tree-box"> <div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" /> <UserMenuAuthTree v-model:checked="form.columnIds" />
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div> <div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
菜单权限加载失败,保存已禁用;请关闭后重试
</div>
</div> </div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button> <el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -126,4 +139,7 @@ async function save(): Promise<void> {
font-size: 12px; font-size: 12px;
color: var(--admin-muted); color: var(--admin-muted);
} }
.auth-loading-error {
color: var(--el-color-danger);
}
</style> </style>
@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填;
// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。
// 修复:授权未就绪时禁用保存并明确提示。
test('edit_user_dialog_blocks_save_until_auth_loaded', () => {
const dialog = readSource('src/pages/account/EditUserDialog.vue')
assert.match(dialog, /authReady/, '存在授权就绪标记')
assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪')
// 加载失败分支必须保持未就绪:不能回退成“可以保存空授权”
const refreshBlock = dialog.slice(
dialog.indexOf('async function refreshAuth'),
dialog.indexOf('function close'),
)
assert.match(
refreshBlock,
/catch \(error\) \{[\s\S]*?authReady\.value = false/,
'授权加载失败保持未就绪,不用空数组覆盖服务端授权',
)
assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪')
// save() 自身兜底拦截,不依赖按钮禁用
const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400)
assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存')
// 保存按钮在未就绪时禁用
assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用')
assert.match(dialog, /加载中/, '加载中有提示')
assert.match(dialog, /保存已禁用/, '加载失败有明确提示')
})
@@ -258,6 +258,9 @@ public class AdminUserService {
} }
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN); replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP); replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
// 两类都落库后再统一级联一次:单类型落库时目标的有效集不完整,提前级联会把
// 员工另一类型的合法授权当越权删掉(生产表现:员工菜单权限“自己没掉”)。
permissionMenuService.cascadeSubordinateOverreach(userId);
} }
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds, private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
@@ -265,7 +268,7 @@ public class AdminUserService {
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest(); UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
permissionRequest.setColumnIds(columnIds); permissionRequest.setColumnIds(columnIds);
permissionMenuService.updateUserColumnPermissions( permissionMenuService.updateUserColumnPermissions(
operator, userId, permissionRequest, menuType); operator, userId, permissionRequest, menuType, false);
} }
public void deleteUser(AdminUserEntity currentUser, Long uid) { public void deleteUser(AdminUserEntity currentUser, Long uid) {
@@ -454,6 +454,21 @@ public class PermissionMenuService {
Long userId, Long userId,
UserColumnPermissionUpdateRequest request, UserColumnPermissionUpdateRequest request,
String menuType) { String menuType) {
updateUserColumnPermissions(operator, userId, request, menuType, true);
}
/**
* @param cascadeSubordinates 是否在本方法收尾时级联清理下属越权授权。调用方若分
* admin/app 多次落库,必须传 false,并在全部落库后调用一次
* {@link #cascadeSubordinateOverreach(Long)}:单次落库时目标只写了其中一个
* 类型,此时级联会按不完整的有效集判定,把员工另一类型的合法授权当越权删掉。
*/
@Transactional
public void updateUserColumnPermissions(AdminUserEntity operator,
Long userId,
UserColumnPermissionUpdateRequest request,
String menuType,
boolean cascadeSubordinates) {
AdminUserEntity target = getUserById(userId); AdminUserEntity target = getUserById(userId);
ensureTargetAccessible(operator, target); ensureTargetAccessible(operator, target);
List<Long> requestedIds = request == null ? List.of() : normalizeColumnIds(request.getColumnIds()); List<Long> requestedIds = request == null ? List.of() : normalizeColumnIds(request.getColumnIds());
@@ -527,7 +542,15 @@ public class PermissionMenuService {
// 目标管理员的权限被回收后,其直接创建的员工若仍持有超出范围的 // 目标管理员的权限被回收后,其直接创建的员工若仍持有超出范围的
// 授权(创建时快照遗留,员工权限独立于管理员后续变更),必须级联 // 授权(创建时快照遗留,员工权限独立于管理员后续变更),必须级联
// 删除,否则回收了管理员的菜单权限,员工菜单权限却还在。 // 删除,否则回收了管理员的菜单权限,员工菜单权限却还在。
cascadeCleanupSubordinateOverreach(target, finalGrantIds); if (cascadeSubordinates) {
cascadeCleanupSubordinateOverreach(target);
}
}
/** 一次逻辑更新(可能分 admin/app 多次落库)全部写完后统一触发一次下属越权清理。 */
@Transactional
public void cascadeSubordinateOverreach(Long targetUserId) {
cascadeCleanupSubordinateOverreach(getUserById(targetUserId));
} }
/** /**
@@ -573,12 +596,17 @@ public class PermissionMenuService {
/** /**
* 删除普通管理员直接创建的员工中,超出该管理员当前有效权限范围的授权。 * 删除普通管理员直接创建的员工中,超出该管理员当前有效权限范围的授权。
* 只清理越权项,保留员工合法持有的其他授权。超级管理员无此限制。 * 只清理越权项,保留员工合法持有的其他授权。超级管理员无此限制。
*
* <p>有效集按目标的<b>完整</b>直接授权(读库,含 admin/app 全类型)计算:
* 调用方可能只更新了单个 menuType,若拿本次提交的 id 当完整集,员工持有的
* 另一类型授权会整体被判越权删除——生产表现就是菜单权限“自己没掉”。</p>
*/ */
private void cascadeCleanupSubordinateOverreach(AdminUserEntity target, Set<Long> targetDirectIds) { private void cascadeCleanupSubordinateOverreach(AdminUserEntity target) {
if (target == null || target.getId() == null || isSuperAdmin(target)) { if (target == null || target.getId() == null || isSuperAdmin(target)) {
return; return;
} }
Set<Long> targetEffectiveIds = expandDescendantIds(new LinkedHashSet<>(targetDirectIds), loadMenus(null)); Set<Long> targetEffectiveIds = expandDescendantIds(
new LinkedHashSet<>(loadDirectColumnIds(target.getId())), loadMenus(null));
List<AdminUserEntity> subordinates = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>() List<AdminUserEntity> subordinates = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getCreatedById, target.getId())); .eq(AdminUserEntity::getCreatedById, target.getId()));
for (AdminUserEntity subordinate : subordinates) { for (AdminUserEntity subordinate : subordinates) {
@@ -54,6 +54,7 @@ public class TaskHeartbeatService {
private static final String MODULE_BRAND = "BRAND"; private static final String MODULE_BRAND = "BRAND";
private final FileTaskMapper fileTaskMapper; private final FileTaskMapper fileTaskMapper;
private final TaskProgressSnapshotService taskProgressSnapshotService;
private final BrandCrawlTaskMapper brandCrawlTaskMapper; private final BrandCrawlTaskMapper brandCrawlTaskMapper;
private final ProductRiskTaskCacheService productRiskTaskCacheService; private final ProductRiskTaskCacheService productRiskTaskCacheService;
private final PublishTaskService publishTaskService; private final PublishTaskService publishTaskService;
@@ -142,9 +143,19 @@ public class TaskHeartbeatService {
if (updated > 0) { if (updated > 0) {
log.warn("[task-interrupted] file task marked failed by client restart taskId={} moduleType={} reason={}", log.warn("[task-interrupted] file task marked failed by client restart taskId={} moduleType={} reason={}",
taskId, fileTask.getModuleType(), safeReason); taskId, fileTask.getModuleType(), safeReason);
// 同步模块缓存与进度快照:progress/batch 读缓存/快照(Redis,跨实例共享、
// 不随 JVM 重启清空),不刷新会让前端一直按 RUNNING 渲染
// (任务面板永远"处理中"并阻塞该工具后续任务)。
fileTask.setStatus("FAILED");
fileTask.setErrorMessage(safeReason);
fileTask.setFinishedAt(LocalDateTime.now());
syncTerminalState(fileTask, "FAILED", safeReason);
return TaskHeartbeatVo.notAlive(fileTask.getModuleType(), "FAILED", "marked failed"); return TaskHeartbeatVo.notAlive(fileTask.getModuleType(), "FAILED", "marked failed");
} }
log.info("[task-interrupted] file task not in RUNNING, skipped taskId={} status={}", taskId, status); log.info("[task-interrupted] file task not in RUNNING, skipped taskId={} status={}", taskId, status);
// 已终态但缓存/快照仍残留 RUNNING 时(历史遗留或上一次中断未刷缓存)自愈:
// 幂等按 DB 现值刷新,避免前端被旧缓存永久卡住。
syncTerminalState(fileTask, status, fileTask.getErrorMessage());
return TaskHeartbeatVo.notAlive(fileTask.getModuleType(), status, "task is not running"); return TaskHeartbeatVo.notAlive(fileTask.getModuleType(), status, "task is not running");
} }
BrandCrawlTaskEntity brandTask = selectBrandTask(taskId); BrandCrawlTaskEntity brandTask = selectBrandTask(taskId);
@@ -342,6 +353,20 @@ public class TaskHeartbeatService {
} }
} }
/** 把终态同步到模块缓存与进度快照;失败只告警,不影响中断接口本身的成功语义。 */
private void syncTerminalState(FileTaskEntity task, String status, String message) {
if (task == null || task.getId() == null) {
return;
}
try {
saveFileTaskCache(task.getModuleType(), task);
taskProgressSnapshotService.markTerminal(task.getId(), task.getModuleType(), status, message);
} catch (Exception ex) {
log.warn("[task-interrupted] refresh cache/snapshot failed taskId={} err={}",
task.getId(), ex.getMessage());
}
}
private void putIfPresent(Map<String, String> values, String key, Object value) { private void putIfPresent(Map<String, String> values, String key, Object value) {
if (value != null) { if (value != null) {
values.put(key, String.valueOf(value)); values.put(key, String.valueOf(value));
@@ -157,6 +157,31 @@ public class TaskProgressSnapshotService {
lastWriteAtMillis.remove(cacheKey(taskId, moduleType)); lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
} }
/**
* 任务被外部置为终态(客户端中断上报 / stale 兜底修复)时同步进度快照。
* 不同步的话,progress/batch 仍按快照里的 RUNNING 回放,前端任务面板永远"处理中"
* 并阻塞该工具的后续任务(2026-09-13 真机:客户端重启后店铺数据采集一直显示 28094 处理中)。
*/
@Transactional
public void markTerminal(Long taskId, String moduleType, String status, String message) {
if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(status)) {
return;
}
TaskProgressSnapshotEntity existing = find(taskId, moduleType);
if (existing == null) {
return;
}
if (Objects.equals(existing.getStatus(), status) && Objects.equals(existing.getMessage(), message)) {
return;
}
taskProgressSnapshotMapper.update(null, new LambdaUpdateWrapper<TaskProgressSnapshotEntity>()
.eq(TaskProgressSnapshotEntity::getId, existing.getId())
.set(TaskProgressSnapshotEntity::getStatus, status)
.set(TaskProgressSnapshotEntity::getMessage, message)
.set(TaskProgressSnapshotEntity::getUpdatedAt, LocalDateTime.now()));
lastWriteAtMillis.remove(cacheKey(taskId, moduleType));
}
private String writeJson(Object value) { private String writeJson(Object value) {
try { try {
return objectMapper.writeValueAsString(value); return objectMapper.writeValueAsString(value);
@@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -58,9 +59,11 @@ class AdminUserServiceTest {
ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq = ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq =
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class); ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
verify(permissionService).updateUserColumnPermissions( verify(permissionService).updateUserColumnPermissions(
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN)); eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN), eq(false));
verify(permissionService).updateUserColumnPermissions( verify(permissionService).updateUserColumnPermissions(
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP)); eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP), eq(false));
// 两类落库完成后只级联清理一次,避免用不完整有效集误删员工另一类型授权
verify(permissionService, times(1)).cascadeSubordinateOverreach(20L);
assertThat(adminReq.getValue().getColumnIds()).containsExactly(11L); assertThat(adminReq.getValue().getColumnIds()).containsExactly(11L);
assertThat(appReq.getValue().getColumnIds()).containsExactly(12L); assertThat(appReq.getValue().getColumnIds()).containsExactly(12L);
assertThat(AdminUserService.class assertThat(AdminUserService.class
@@ -95,9 +98,11 @@ class AdminUserServiceTest {
ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq = ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq =
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class); ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
verify(permissionService).updateUserColumnPermissions( verify(permissionService).updateUserColumnPermissions(
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN)); eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN), eq(false));
verify(permissionService).updateUserColumnPermissions( verify(permissionService).updateUserColumnPermissions(
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP)); eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP), eq(false));
// 两类落库完成后只级联清理一次,避免用不完整有效集误删员工另一类型授权
verify(permissionService, times(1)).cascadeSubordinateOverreach(20L);
assertThat(adminReq.getValue().getColumnIds()).isEmpty(); assertThat(adminReq.getValue().getColumnIds()).isEmpty();
assertThat(appReq.getValue().getColumnIds()).containsExactly(21L); assertThat(appReq.getValue().getColumnIds()).containsExactly(21L);
assertThat(AdminUserService.class assertThat(AdminUserService.class
@@ -21,6 +21,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@@ -434,9 +435,9 @@ class PermissionMenuServiceTest {
when(menuMapper.selectList(any())).thenReturn(List.of( when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "admin", 1), menu(1L, null, "admin", 1),
menu(2L, null, "admin", 2))); menu(2L, null, "admin", 2)));
// 管理员当前授权:1(新)+ 2(被回收前存在) // 目标当前授权:级联读的是本次落库后的状态——管理员只剩 1,2 已被回收。
when(permissionMapper.selectByUserId(10L)) when(permissionMapper.selectByUserId(10L))
.thenReturn(List.of(grant(10L, 1L), grant(10L, 2L))); .thenReturn(List.of(grant(10L, 1L)));
// 员工 20 的授权:1(合法)+ 2(越权,应被级联删除) // 员工 20 的授权:1(合法)+ 2(越权,应被级联删除)
when(permissionMapper.selectByUserId(20L)) when(permissionMapper.selectByUserId(20L))
.thenReturn(List.of(grant(20L, 1L), grant(20L, 2L))); .thenReturn(List.of(grant(20L, 1L), grant(20L, 2L)));
@@ -450,6 +451,75 @@ class PermissionMenuServiceTest {
verify(permissionMapper).deleteByUserIdAndColumnIds(20L, List.of(2L)); verify(permissionMapper).deleteByUserIdAndColumnIds(20L, List.of(2L));
} }
@Test
void deferredCascadeKeepsSubordinateGrantsOfOtherMenuType() {
// 回归(生产:员工菜单权限“自己没掉”):admin/app 分区落库时若在每个分区后立刻级联,
// 目标的有效集只含当前类型,员工另一类型的合法授权会被整体误判越权删除。
// 修复后:两类落库完再统一级联一次,按目标完整直接授权判定。
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(1L, "super_admin", 1);
AdminUserEntity target = user(10L, "admin", 1);
target.setCreatedById(1L);
AdminUserEntity subordinate = user(20L, "normal", 0);
subordinate.setCreatedById(10L);
when(userMapper.selectById(10L)).thenReturn(target);
when(menuMapper.selectList(any())).thenReturn(List.of(
menu(1L, null, "admin", 1),
menu(11L, null, "app", 1)));
when(menuMapper.selectOne(any())).thenReturn(null);
// 目标本次落库后的完整直接授权:admin 1 + app 11
when(permissionMapper.selectByUserId(10L))
.thenReturn(List.of(grant(10L, 1L), grant(10L, 11L)));
// 员工持有:1(admin 合法)、11(app 合法)、99(越权,菜单表里不存在)
when(permissionMapper.selectByUserId(20L))
.thenReturn(List.of(grant(20L, 1L), grant(20L, 11L), grant(20L, 99L)));
when(userMapper.selectList(any())).thenReturn(List.of(subordinate));
UserColumnPermissionUpdateRequest adminRequest = new UserColumnPermissionUpdateRequest();
adminRequest.setColumnIds(List.of(1L));
UserColumnPermissionUpdateRequest appRequest = new UserColumnPermissionUpdateRequest();
appRequest.setColumnIds(List.of(11L));
service.updateUserColumnPermissions(
operator, 10L, adminRequest, PermissionMenuService.MENU_TYPE_ADMIN, false);
service.updateUserColumnPermissions(
operator, 10L, appRequest, PermissionMenuService.MENU_TYPE_APP, false);
service.cascadeSubordinateOverreach(10L);
// 只有真正越权的 99 被清理,另一类型(app 11)的合法授权必须保留
verify(permissionMapper).deleteByUserIdAndColumnIds(20L, List.of(99L));
}
@Test
void perTypeReplacementDoesNotCascadeBeforeAllTypesWritten() {
// 分区落库期间(cascadeSubordinates=false)不得触发下属清理,否则会拿不完整的
// 有效集误删员工另一类型授权。
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
AdminUserEntity operator = user(1L, "super_admin", 1);
AdminUserEntity target = user(10L, "admin", 1);
target.setCreatedById(1L);
when(userMapper.selectById(10L)).thenReturn(target);
when(menuMapper.selectList(any())).thenReturn(List.of(menu(1L, null, "admin", 1)));
when(menuMapper.selectOne(any())).thenReturn(null);
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
request.setColumnIds(List.of(1L));
service.updateUserColumnPermissions(
operator, 10L, request, PermissionMenuService.MENU_TYPE_ADMIN, false);
verify(userMapper, never()).selectList(any());
// 目标自身分区的整树替换会删自己的授权,但不得波及任何下属
verify(permissionMapper, never()).deleteByUserIdAndColumnIds(eq(20L), any());
}
@Test @Test
void replacementStoresOnlyDirectRequestedParentId() { void replacementStoresOnlyDirectRequestedParentId() {
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);