feat(密钥管理): 按数据权限分组隔离与筛选

- 后台密钥列表改为按 biz_shop_manage_group 分组:主管只看自己带的分组成员,超管看全量可按 group_id 筛选
- 行数据补「所属分组」名称(批量查询);列表接口带回分组筛选项
- 修复超管筛选不生效:前端筛选参数统一 snake_case(camel 被后端静默忽略)
- 列表列/筛选项文案由「所属管理员」改为「分组」
This commit is contained in:
2026-09-13 13:34:08 +08:00
parent 05ae0c62d6
commit 1b733b23b9
10 changed files with 251 additions and 96 deletions
@@ -102,4 +102,37 @@ public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity>
</script>
""")
List<Long> selectUserIdsByGroupIds(@Param("groupIds") List<Long> groupIds);
/** 批量查用户所属分组名(密钥管理列表「分组」列展示用)。 */
@Select("""
<script>
SELECT gm.user_id AS userId, g.group_name AS groupName
FROM biz_shop_manage_group_member gm
INNER JOIN biz_shop_manage_group g ON g.id = gm.group_id
WHERE gm.user_id IN
<foreach collection='userIds' item='userId' open='(' separator=',' close=')'>
#{userId}
</foreach>
ORDER BY g.id ASC
</script>
""")
List<com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef> selectGroupNamesByUserIds(
@Param("userIds") List<Long> userIds);
/** 某人作为组长(创建人)的分组(密钥管理等按组隔离场景用)。 */
@Select("""
SELECT g.id AS id, g.group_name AS groupName
FROM biz_shop_manage_group g
WHERE g.created_by_id = #{operatorId} OR g.user_id = #{operatorId}
ORDER BY g.id ASC
""")
List<ShopManageGroupEntity> selectLedGroups(@Param("operatorId") Long operatorId);
/** 全部分组(超管筛选项用)。 */
@Select("""
SELECT g.id AS id, g.group_name AS groupName
FROM biz_shop_manage_group g
ORDER BY g.id ASC
""")
List<ShopManageGroupEntity> selectAllGroups();
}
@@ -0,0 +1,12 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import lombok.Data;
/** 用户→分组名映射行(密钥管理列表展示用户所属分组用)。 */
@Data
public class UserGroupRef {
private Long userId;
private String groupName;
}
@@ -39,19 +39,19 @@ public class AdminUserApiSecretController {
private final AdminAuthSupport adminAuthSupport;
@GetMapping
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。主管只能看自己名下子账户,超管看全量并可按创建人筛选。")
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。主管只能看自己带的分组成员,超管看全量并可按分组筛选。")
public ApiResponse<AdminUserSecretPageVo> page(
HttpServletRequest request,
@Parameter(description = "关键字:用户名") @RequestParam(required = false) String keyword,
@Parameter(description = "行级状态筛选:passed/failed/incomplete/error/unknown") @RequestParam(required = false) String checkStatus,
@Parameter(description = "创建人筛选(仅超管生效)") @RequestParam(name = "created_by_id", required = false) Long createdById,
@Parameter(description = "数据权限分组筛选(仅超管生效)") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
AdminUserEntity operator = adminAuthSupport.requireAdmin(request);
AdminUserSecretQuery query = new AdminUserSecretQuery();
query.setKeyword(keyword);
query.setCheckStatus(checkStatus);
query.setCreatedById(createdById);
query.setGroupId(groupId);
query.setPage(page);
query.setPageSize(pageSize);
return ApiResponse.success(userApiSecretService.adminPage(operator, query));
@@ -13,8 +13,8 @@ public class AdminUserSecretQuery {
@Schema(description = "行级状态筛选:passed/failed/incomplete/error/unknown")
private String checkStatus;
@Schema(description = "创建人筛选(超管生效;主管强制为组)")
private Long createdById;
@Schema(description = "数据权限分组筛选(超管可选;主管强制为自己带的分组)")
private Long groupId;
@Schema(description = "页码,从 1 开始")
private Long page = 1L;
@@ -20,4 +20,11 @@ public class AdminUserSecretPageVo {
@Schema(description = "每页数量")
private Long pageSize;
@Schema(description = "分组筛选项(超管=全部分组,主管=自己带的分组)")
private List<GroupOptionVo> groupOptions;
@Schema(description = "分组筛选项")
public record GroupOptionVo(Long id, String groupName) {
}
}
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Schema(description = "后台密钥管理-单用户一行(三个字段列 + 行级状态)")
@@ -15,11 +16,8 @@ public class AdminUserSecretRowVo {
@Schema(description = "用户名")
private String username;
@Schema(description = "所属管理员(创建人)ID")
private Long createdById;
@Schema(description = "所属管理员用户名")
private String createdByUsername;
@Schema(description = "所属分组名(数据权限分组,可能多个)")
private List<String> groups;
@Schema(description = "货源查询密钥")
private AdminUserSecretModuleVo similarAsin;
@@ -6,6 +6,9 @@ import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
@@ -69,6 +72,7 @@ public class UserApiSecretService {
private final JikipProxyClient jikipProxyClient;
private final AdminUserMapper adminUserMapper;
private final AdminAuthSupport adminAuthSupport;
private final ShopManageGroupMapper adminGroupMapper;
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
public UserApiSecretBundleVo bundle(Long userId) {
@@ -200,43 +204,50 @@ public class UserApiSecretService {
* 先按关键字圈定用户,再全量聚合、行级状态筛选、按最近更新时间倒序后内存分页
* (当前规模为用户数×3,内存聚合成本可控;量级上来后可改为 GROUP BY 下推)。
*/
/** 后台分页查询:主管(admin)只看自己名下子账户(created_by_id=自己),超管看全量可按创建人筛选。 */
/** 后台分页查询:主管(admin)只看自己带的「数据权限分组」成员;超管看全量可按分组筛选。 */
public AdminUserSecretPageVo adminPage(AdminUserEntity operator, AdminUserSecretQuery query) {
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1
? 15L : Math.min(safeQuery.getPageSize(), 100L);
// 组隔离:主管强制锁定本组;超管可按 created_by_id 筛选(0 或空 = 全部)。
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
Long scopedCreatedById;
Long requestedGroupId = safeQuery.getGroupId();
List<Long> ledGroupIds = superAdmin ? List.of() : listLedGroupIds(operator.getId());
// 组隔离:主管强制锁定自己带的分组;超管按 group_id 筛选(空 = 全部)。
List<Long> scopedGroupIds;
if (superAdmin) {
Long filter = safeQuery.getCreatedById();
scopedCreatedById = filter != null && filter > 0 ? filter : null;
scopedGroupIds = requestedGroupId != null && requestedGroupId > 0 ? List.of(requestedGroupId) : null;
} else {
scopedCreatedById = operator.getId();
if (ledGroupIds.isEmpty()) {
log.info("[user-secret] 后台密钥列表:主管未带任何分组,返回空 operatorId={}", operator.getId());
return emptyPageWithGroups(page, pageSize, List.of());
}
scopedGroupIds = ledGroupIds;
}
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
String keyword = normalize(safeQuery.getKeyword());
List<Long> allowedUserIds = null;
// 用户存在性/归属先按 users 表圈定:keyword 匹配 + 组隔离前置过滤(无密钥记录的用户本来就不在聚合表里)
LambdaQueryWrapper<AdminUserEntity> userWrapper = new LambdaQueryWrapper<>();
if (!keyword.isEmpty()) {
userWrapper.like(AdminUserEntity::getUsername, keyword);
}
if (scopedCreatedById != null) {
userWrapper.eq(AdminUserEntity::getCreatedById, scopedCreatedById);
}
if (!keyword.isEmpty() || scopedCreatedById != null) {
allowedUserIds = adminUserMapper.selectList(userWrapper).stream()
.map(AdminUserEntity::getId)
.filter(id -> id != null)
.toList();
if (!keyword.isEmpty() || scopedGroupIds != null) {
// 分组圈定成员用户(含组长本人);再叠加用户名关键字
List<Long> allowedUserIds = scopedGroupIds == null
? new ArrayList<>()
: new ArrayList<>(adminGroupMapper.selectUserIdsByGroupIds(scopedGroupIds));
if (!keyword.isEmpty()) {
List<Long> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.like(AdminUserEntity::getUsername, keyword)
.last("limit 200"))
.stream().map(AdminUserEntity::getId).filter(id -> id != null).toList();
if (scopedGroupIds == null) {
allowedUserIds = new ArrayList<>(matched);
} else {
allowedUserIds.retainAll(matched);
}
}
if (allowedUserIds.isEmpty()) {
log.info("[user-secret] 后台密钥列表无匹配用户 keyword={} createdById={} operatorId={} role={}",
keyword, scopedCreatedById, operator.getId(), superAdmin ? "super_admin" : "admin");
return emptyPage(page, pageSize);
log.info("[user-secret] 后台密钥列表无匹配用户 keyword={} groupIds={} operatorId={} role={}",
keyword, scopedGroupIds, operator.getId(), superAdmin ? "super_admin" : "admin");
return emptyPageWithGroups(page, pageSize, groupOptions(operator, superAdmin, ledGroupIds));
}
wrapper.in(UserApiSecretEntity::getUserId, allowedUserIds);
}
@@ -265,13 +276,77 @@ public class UserApiSecretService {
long total = all.size();
int from = (int) Math.min((page - 1) * pageSize, total);
int to = (int) Math.min(from + pageSize, total);
List<AdminUserSecretRowVo> pageItems = new ArrayList<>(all.subList(from, to));
fillRowGroups(pageItems);
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
vo.setItems(new ArrayList<>(all.subList(from, to)));
vo.setItems(pageItems);
vo.setTotal(total);
vo.setPage(page);
vo.setPageSize(pageSize);
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} createdById={} role={} 聚合用户数={} 本页返回={}",
keyword, statusFilter, scopedCreatedById, superAdmin ? "super_admin" : "admin", total, vo.getItems().size());
vo.setGroupOptions(groupOptions(operator, superAdmin, ledGroupIds));
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} groupIds={} role={} 聚合用户数={} 本页返回={}",
keyword, statusFilter, scopedGroupIds, superAdmin ? "super_admin" : "admin", total, pageItems.size());
return vo;
}
/** 主管带的分组 IDcreated_by_id / user_id = 自己)。 */
private List<Long> listLedGroupIds(Long operatorId) {
if (operatorId == null) {
return List.of();
}
return adminGroupMapper.selectLedGroups(operatorId).stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.toList();
}
/** 分组筛选项:超管=全部;主管=自己带的分组(没有则不展示下拉)。 */
private List<AdminUserSecretPageVo.GroupOptionVo> groupOptions(
AdminUserEntity operator, boolean superAdmin, List<Long> ledGroupIds) {
List<ShopManageGroupEntity> groups;
if (superAdmin) {
groups = adminGroupMapper.selectAllGroups();
} else if (ledGroupIds.isEmpty()) {
return List.of();
} else {
groups = adminGroupMapper.selectLedGroups(operator.getId());
}
return groups.stream()
.filter(group -> group.getId() != null)
.map(group -> new AdminUserSecretPageVo.GroupOptionVo(group.getId(), group.getGroupName()))
.toList();
}
/** 本页行补「所属分组」:一次批量查询,避免逐行查库。 */
private void fillRowGroups(List<AdminUserSecretRowVo> pageItems) {
List<Long> userIds = pageItems.stream()
.map(AdminUserSecretRowVo::getUserId)
.filter(id -> id != null)
.toList();
if (userIds.isEmpty()) {
return;
}
Map<Long, List<String>> byUser = new LinkedHashMap<>();
for (UserGroupRef ref : adminGroupMapper.selectGroupNamesByUserIds(userIds)) {
if (ref.getUserId() == null) {
continue;
}
String name = normalize(ref.getGroupName());
if (name.isEmpty()) {
continue;
}
byUser.computeIfAbsent(ref.getUserId(), key -> new ArrayList<>()).add(name);
}
for (AdminUserSecretRowVo item : pageItems) {
item.setGroups(byUser.getOrDefault(item.getUserId(), List.of()));
}
}
private AdminUserSecretPageVo emptyPageWithGroups(
long page, long pageSize, List<AdminUserSecretPageVo.GroupOptionVo> groupOptions) {
AdminUserSecretPageVo vo = emptyPage(page, pageSize);
vo.setGroupOptions(groupOptions);
return vo;
}
@@ -412,11 +487,7 @@ public class UserApiSecretService {
vo.setUpdatedAt(latestUpdatedAt(modules));
AdminUserEntity user = adminUserMapper.selectById(userId);
vo.setUsername(user == null ? "" : user.getUsername());
if (user != null && user.getCreatedById() != null) {
vo.setCreatedById(user.getCreatedById());
AdminUserEntity creator = adminUserMapper.selectById(user.getCreatedById());
vo.setCreatedByUsername(creator == null ? "" : creator.getUsername());
}
vo.setGroups(List.of());
return vo;
}
@@ -4,6 +4,8 @@ import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
@@ -32,6 +34,8 @@ class UserApiSecretServiceTest {
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
private final com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper adminGroupMapper =
mock(com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper.class);
private UserApiSecretService newService() {
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
@@ -41,7 +45,8 @@ class UserApiSecretServiceTest {
});
// 默认按主管(admin)判定;超管用例里单独改打桩。
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport);
return new UserApiSecretService(
mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper);
}
@Test
@@ -210,8 +215,6 @@ class UserApiSecretServiceTest {
@Test
void adminPageAggregatesOneRowPerUserWithProxyMasked() {
UserApiSecretService service = newService();
// 非限定查询:无 keyword 无组过滤时不触发 users 表圈定查询。
when(adminUserMapper.selectList(any())).thenReturn(List.of());
when(mapper.selectList(any())).thenReturn(List.of(
row(1L, "similar-asin", "enc:sk-sa-1234", "passed"),
row(1L, "appearance-patent", "enc:sk-ap-5678", "passed"),
@@ -220,11 +223,11 @@ class UserApiSecretServiceTest {
user.setId(1L);
user.setUsername("张三");
when(adminUserMapper.selectById(1L)).thenReturn(user);
AdminUserEntity creator = new AdminUserEntity();
creator.setId(99L);
creator.setUsername("主管甲");
when(adminUserMapper.selectById(99L)).thenReturn(creator);
user.setCreatedById(99L);
// 分组列:用户 1 属于「一组」
UserGroupRef ref = new UserGroupRef();
ref.setUserId(1L);
ref.setGroupName("一组");
when(adminGroupMapper.selectGroupNamesByUserIds(any())).thenReturn(List.of(ref));
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
@@ -234,8 +237,7 @@ class UserApiSecretServiceTest {
assertThat(page.getItems()).hasSize(1);
var rowVo = page.getItems().get(0);
assertThat(rowVo.getUsername()).isEqualTo("张三");
assertThat(rowVo.getCreatedById()).isEqualTo(99L);
assertThat(rowVo.getCreatedByUsername()).isEqualTo("主管甲");
assertThat(rowVo.getGroups()).containsExactly("一组");
assertThat(rowVo.getStatus()).isEqualTo("failed");
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
assertThat(rowVo.getProxy().getExists()).isTrue();
@@ -243,20 +245,61 @@ class UserApiSecretServiceTest {
}
@Test
void adminPageLocksAdminToOwnGroupUsers() {
void adminPageLocksAdminToOwnLedGroups() {
UserApiSecretService service = newService();
// 主管无论传什么 createdById,都强制锁定为本组(created_by_id=88L)。
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
when(adminUserMapper.selectList(any())).thenReturn(List.of());
// 主管未带任何分组 → 直接空页,不查密钥表。
when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of());
service.adminPage(operator, new AdminUserSecretQuery());
var page = service.adminPage(operator, new AdminUserSecretQuery());
// 主管模式:先圈定 users 表(组过滤),密钥表因无匹配行不再查询。
verify(adminUserMapper).selectList(any());
assertThat(page.getItems()).isEmpty();
verify(mapper, never()).selectList(any());
}
@Test
void adminPageScopesToLedGroupMembers() {
UserApiSecretService service = newService();
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
ShopManageGroupEntity group = new ShopManageGroupEntity();
group.setId(5L);
group.setGroupName("一组");
when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of(group));
when(adminGroupMapper.selectUserIdsByGroupIds(any())).thenReturn(List.of(1L));
when(mapper.selectList(any())).thenReturn(List.of(row(1L, "similar-asin", "enc:sk-1", "passed")));
AdminUserEntity user = new AdminUserEntity();
user.setId(1L);
user.setUsername("张三");
when(adminUserMapper.selectById(1L)).thenReturn(user);
when(adminGroupMapper.selectGroupNamesByUserIds(any())).thenReturn(List.of());
var page = service.adminPage(operator, new AdminUserSecretQuery());
assertThat(page.getItems()).hasSize(1);
assertThat(page.getGroupOptions()).extracting(com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo.GroupOptionVo::groupName)
.containsExactly("一组");
verify(adminGroupMapper).selectUserIdsByGroupIds(any());
}
@Test
void adminPageFiltersSuperAdminByGroupId() {
UserApiSecretService service = newService();
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
when(adminAuthSupport.currentRole(operator)).thenReturn("super_admin");
when(adminGroupMapper.selectUserIdsByGroupIds(List.of(5L))).thenReturn(List.of());
when(adminGroupMapper.selectAllGroups()).thenReturn(List.of());
AdminUserSecretQuery query = new AdminUserSecretQuery();
query.setGroupId(5L);
var page = service.adminPage(operator, query);
assertThat(page.getItems()).isEmpty();
verify(adminGroupMapper).selectUserIdsByGroupIds(List.of(5L));
}
@Test
void adminClearByUserDeletesAllRowsOfUser() {
UserApiSecretService service = newService();