优化
This commit is contained in:
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.dto.RbacDtos.*;
|
||||
import com.baiyue.iam.entity.IamPermission;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.service.RbacService;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -45,7 +44,7 @@ public class RbacController {
|
||||
@PostMapping("/roles")
|
||||
@Operation(summary = "创建角色")
|
||||
@PreAuthorize("hasAuthority(#app + ':role:create')")
|
||||
public ApiResponse<IamRole> createRole(@PathVariable String app,
|
||||
public ApiResponse<RoleResponse> createRole(@PathVariable String app,
|
||||
@Valid @RequestBody CreateRoleRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 企业成员接口的数据契约。 */
|
||||
@@ -44,8 +45,9 @@ public final class MemberDtos {
|
||||
public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
||||
|
||||
public record MemberResponse(Long membershipId, Long userId, Long authUserId,
|
||||
public record MemberResponse(Long membershipId, Long userId, Long authUserId, String username,
|
||||
String displayName, String phone, String email,
|
||||
String memberType, String memberNo, String status, List<Long> roleIds) { }
|
||||
String memberType, String memberNo, String status, LocalDateTime lastLoginAt,
|
||||
List<Long> roleIds) { }
|
||||
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public final class RbacDtos {
|
||||
/** 角色回显模型,permissionIds 可直接用于全量授权保存。 */
|
||||
public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode,
|
||||
String roleName, String roleType, String status,
|
||||
String description, Set<Long> permissionIds) { }
|
||||
String description, long memberCount, Set<Long> permissionIds) { }
|
||||
|
||||
public record MemberRolesResponse(Long membershipId, Set<Long> roleIds) { }
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */
|
||||
@@ -132,6 +134,23 @@ public class RbacRepository {
|
||||
.eq(IamMembershipRole::getRoleId, roleId)) > 0;
|
||||
}
|
||||
|
||||
public long countRoleMembers(long tenantId, Long roleId) {
|
||||
return membershipRoleMapper.selectCount(new LambdaQueryWrapper<IamMembershipRole>()
|
||||
.eq(IamMembershipRole::getTenantId, tenantId)
|
||||
.eq(IamMembershipRole::getRoleId, roleId));
|
||||
}
|
||||
|
||||
public Map<Long, Long> findRoleMemberCounts(long tenantId, List<Long> roleIds) {
|
||||
if (roleIds.isEmpty()) return Map.of();
|
||||
List<IamMembershipRole> assignments = membershipRoleMapper.selectList(
|
||||
new LambdaQueryWrapper<IamMembershipRole>()
|
||||
.eq(IamMembershipRole::getTenantId, tenantId)
|
||||
.in(IamMembershipRole::getRoleId, roleIds));
|
||||
Map<Long, Long> counts = new LinkedHashMap<>();
|
||||
assignments.forEach(assignment -> counts.merge(assignment.getRoleId(), 1L, Long::sum));
|
||||
return counts;
|
||||
}
|
||||
|
||||
public List<Long> findAssignedAuthUserIds(long tenantId, Long roleId) {
|
||||
return membershipRoleMapper.findAssignedAuthUserIds(tenantId, roleId);
|
||||
}
|
||||
|
||||
@@ -189,8 +189,9 @@ public class MemberService {
|
||||
return response(tenantId, membership, user);
|
||||
}
|
||||
private MemberResponse response(long tenantId, IamMembership m, IamUser u) {
|
||||
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(),
|
||||
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(),
|
||||
AuthAccountClient.AccountSummary account = accountSummary(u.getAuthUserId());
|
||||
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), account.username(), u.getDisplayName(),
|
||||
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), account.lastLoginAt(),
|
||||
repository.findRoleIds(tenantId, m.getId()));
|
||||
}
|
||||
private IamMembership required(long tenantId, Long membershipId) {
|
||||
|
||||
@@ -37,19 +37,24 @@ public class RbacService {
|
||||
private String internalToken;
|
||||
|
||||
public List<RoleResponse> roles(long tenantId, String appCode) {
|
||||
return repository.findRoles(tenantId, appCode).stream()
|
||||
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId())))
|
||||
List<IamRole> roles = repository.findRoles(tenantId, appCode);
|
||||
Map<Long, Long> memberCounts = repository.findRoleMemberCounts(
|
||||
tenantId, roles.stream().map(IamRole::getId).toList());
|
||||
return roles.stream()
|
||||
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()),
|
||||
memberCounts.getOrDefault(role.getId(), 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public RoleResponse role(long tenantId, String appCode, Long roleId) {
|
||||
IamRole role = requireRole(tenantId, appCode, roleId);
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId));
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
||||
repository.countRoleMembers(tenantId, roleId));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode",
|
||||
description = "创建角色", captureBefore = false, captureAfter = true)
|
||||
public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) {
|
||||
public RoleResponse createRole(long tenantId, String appCode, CreateRoleRequest request) {
|
||||
if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
|
||||
}
|
||||
@@ -68,7 +73,7 @@ public class RbacService {
|
||||
// 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。
|
||||
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
|
||||
}
|
||||
return role;
|
||||
return response(role, Set.of(), 0L);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId",
|
||||
@@ -78,7 +83,8 @@ public class RbacService {
|
||||
role.setRoleName(request.roleName());
|
||||
role.setDescription(request.description());
|
||||
repository.updateRole(role);
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId));
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
||||
repository.countRoleMembers(tenantId, roleId));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId",
|
||||
@@ -90,7 +96,8 @@ public class RbacService {
|
||||
role.setStatus(request.status());
|
||||
repository.updateRole(role);
|
||||
revokeAssignedSessions(tenantId, roleId);
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId));
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
||||
repository.countRoleMembers(tenantId, roleId));
|
||||
}
|
||||
|
||||
/** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */
|
||||
@@ -319,10 +326,10 @@ public class RbacService {
|
||||
return membership;
|
||||
}
|
||||
|
||||
private RoleResponse response(IamRole role, Set<Long> permissionIds) {
|
||||
private RoleResponse response(IamRole role, Set<Long> permissionIds, long memberCount) {
|
||||
return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(),
|
||||
role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(),
|
||||
Set.copyOf(permissionIds));
|
||||
memberCount, Set.copyOf(permissionIds));
|
||||
}
|
||||
|
||||
private BizException forbidden() {
|
||||
|
||||
@@ -502,7 +502,11 @@ paths:
|
||||
tags: [Merchant Tenant / 商户企业]
|
||||
summary: List enterprise members / 查询企业成员
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 企业成员列表,包含真实登录账号与最后登录时间
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberListApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
post:
|
||||
tags: [Merchant Tenant / 商户企业]
|
||||
@@ -514,7 +518,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CreateMemberRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 新增后的企业成员
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
@@ -530,7 +538,11 @@ paths:
|
||||
summary: 查询成员详情及当前角色
|
||||
description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 企业成员详情
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
put:
|
||||
@@ -543,7 +555,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UpdateMemberRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 更新后的企业成员
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
@@ -552,7 +568,11 @@ paths:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: List tenant addresses / 查询企业地址
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 角色列表及各角色实时成员数
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RoleListApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
@@ -563,7 +583,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AddressRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 新增后的角色及实时成员数
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RoleApiResponse'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/api/merchant/v1/iam/tenant/qualifications:
|
||||
@@ -887,6 +911,60 @@ components:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: {type: string}
|
||||
MerchantMemberResponse:
|
||||
type: object
|
||||
required: [membershipId, userId, authUserId, username, displayName, memberType, status, lastLoginAt, roleIds]
|
||||
properties:
|
||||
membershipId: {type: string, pattern: '^\d+$'}
|
||||
userId: {type: string, pattern: '^\d+$'}
|
||||
authUserId: {type: string, pattern: '^\d+$'}
|
||||
username: {type: string, description: 统一认证登录账号}
|
||||
displayName: {type: string}
|
||||
phone: {type: string, nullable: true}
|
||||
email: {type: string, format: email, nullable: true}
|
||||
memberType: {type: string, enum: [OWNER, EMPLOYEE]}
|
||||
memberNo: {type: string, nullable: true, description: 企业内部成员编号,不作为登录账号}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
lastLoginAt: {type: string, format: date-time, nullable: true, description: 从 Auth 账号事实返回;null 表示从未登录}
|
||||
roleIds: {type: array, items: {type: string, pattern: '^\d+$'}}
|
||||
MerchantMemberListApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/MerchantMemberResponse'}}
|
||||
MerchantMemberApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/MerchantMemberResponse'}
|
||||
RoleResponse:
|
||||
type: object
|
||||
required: [id, tenantId, appCode, roleCode, roleName, roleType, status, memberCount, permissionIds]
|
||||
properties:
|
||||
id: {type: string, pattern: '^\d+$'}
|
||||
tenantId: {type: string, pattern: '^\d+$'}
|
||||
appCode: {type: string, enum: [PLATFORM, MERCHANT]}
|
||||
roleCode: {type: string}
|
||||
roleName: {type: string}
|
||||
roleType: {type: string, enum: [SYSTEM, CUSTOM]}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
description: {type: string, nullable: true}
|
||||
memberCount: {type: integer, format: int64, minimum: 0, description: 当前客户及应用域内分配该角色的成员数}
|
||||
permissionIds: {type: array, uniqueItems: true, items: {type: string, pattern: '^\d+$'}}
|
||||
RoleListApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/RoleResponse'}}
|
||||
RoleApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/RoleResponse'}
|
||||
TenantPageApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
|
||||
@@ -40,6 +40,11 @@ class IamOpenApiContractTest {
|
||||
.contains("TenantAddressListApiResponse:")
|
||||
.contains("TenantQualificationListApiResponse:")
|
||||
.contains("InternalUserListApiResponse:")
|
||||
.contains("MerchantMemberResponse:")
|
||||
.contains("username: {type: string, description: 统一认证登录账号}")
|
||||
.contains("lastLoginAt: {type: string, format: date-time, nullable: true")
|
||||
.contains("RoleResponse:")
|
||||
.contains("memberCount: {type: integer, format: int64, minimum: 0")
|
||||
.contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:")
|
||||
.contains("AssignInternalUserRolesRequest:")
|
||||
.contains("/api/merchant/v1/iam/members/{membershipId}:")
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -44,15 +45,20 @@ class MemberServiceTest {
|
||||
when(repository.findByTenantAndId(100L, 20L)).thenReturn(member);
|
||||
when(repository.findUser(30L)).thenReturn(user);
|
||||
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
|
||||
when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(40L, "member01", "测试成员", "ACTIVE",
|
||||
LocalDateTime.of(2026, 8, 17, 10, 30), 100L, 20L, "MERCHANT")));
|
||||
}
|
||||
|
||||
@Test void shouldHandleCase01() {
|
||||
user.setPhone("13800138000"); user.setEmail("member@example.com");
|
||||
MemberResponse result = service.detail(100L, 20L);
|
||||
assertEquals("测试成员", result.displayName());
|
||||
assertEquals("member01", result.username());
|
||||
assertEquals("13800138000", result.phone());
|
||||
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 30), result.lastLoginAt());
|
||||
assertEquals(List.of(50L), result.roleIds());
|
||||
verifyNoInteractions(auth);
|
||||
verify(auth).summary("internal-token", 40L);
|
||||
}
|
||||
|
||||
@Test void shouldReturnOwnerAccountForPlatformCustomerDetail() {
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.springframework.test.context.TestPropertySource;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -46,11 +47,14 @@ class RbacServiceTest {
|
||||
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
|
||||
when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role));
|
||||
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L));
|
||||
when(repository.findRoleMemberCounts(100L, List.of(11L))).thenReturn(Map.of(11L, 3L));
|
||||
|
||||
var result = service.roles(100L, "MERCHANT");
|
||||
|
||||
assertEquals(Set.of(21L, 22L), result.get(0).permissionIds());
|
||||
assertEquals(3L, result.get(0).memberCount());
|
||||
assertEquals(100L, result.get(0).tenantId());
|
||||
verify(repository, never()).countRoleMembers(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user