This commit is contained in:
2026-08-17 19:51:54 +08:00
parent cddd23e4c7
commit 45b51415cf
84 changed files with 1278 additions and 158 deletions
@@ -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() {
+84 -6
View File
@@ -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