diff --git a/iam/AGENTS.md b/iam/AGENTS.md index daba57927..ec558a648 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -16,7 +16,7 @@ | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | | 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` | | 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 | -| 平台统一用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;统一维护平台与企业租户账号,选项接口返回租户和角色 | +| 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台租户和 PLATFORM 角色 | | 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 | | 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | @@ -26,12 +26,12 @@ | 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 | | 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 | | OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 | -| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、统一用户、平台资质和 RBAC 测试 | +| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、内部用户、平台资质和 RBAC 测试 | ## 修改与验证 -- `/api/platform/v1/iam/users` 是后台“用户管理”的统一入口,覆盖 `tenant_id=0` 的平台账号和企业租户成员;`/iam/internal-users` 是较早的平台内部用户专用入口,定位问题时不要混用 Controller/Service。 -- 统一用户创建要求 `platform:user:create` 与 `platform:user:grant-role`;跨租户或修改角色还需授角权限。角色必须属于目标租户和对应 `PLATFORM/MERCHANT` appCode,受保护的平台管理员不得被降权、停用或自停用。 -- 跨租户移动用户时必须同步成员租户、角色和 Auth 账号 scope;协作入口为 `client/AuthAccountClient.java` 与 Auth 的 `PUT /internal/auth/accounts/{id}/scope`,任一步失败都应回滚本地事务或执行补偿。 +- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业租户成员必须走商户成员边界。 +- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台租户且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。 +- `/users` 不提供跨租户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。 - 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。 - 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。 - 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。 diff --git a/iam/README-IAM.md b/iam/README-IAM.md index a85df6285..dc356e983 100644 --- a/iam/README-IAM.md +++ b/iam/README-IAM.md @@ -20,9 +20,9 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe | GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 | | POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 | -| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Unified platform and merchant user administration / 统一用户管理 | -| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Tenant and role options / 用户租户与角色选项 | -| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit profile, tenant and roles / 编辑用户资料、租户和角色 | +| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Paginated platform-internal user administration (`tenant_id=0`) / 平台内部用户分页管理 | +| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform tenant and PLATFORM role options / 平台租户与平台角色选项 | +| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit internal-user profile and roles / 编辑内部用户资料和角色 | | PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 | | PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 | | GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 | diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java index 54bb5fbe3..c3b7c8e37 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java @@ -1,6 +1,7 @@ package com.baiyue.iam.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.service.PlatformUserManagementService; @@ -8,15 +9,18 @@ import com.baiyue.iam.service.TenantGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.util.List; - -/** 原型“用户管理”的平台统一入口,覆盖平台与企业租户账号。 */ +/** Admin 内部用户管理入口,只允许维护平台租户账号。 */ @RestController @RequestMapping("/api/platform/v1/iam/users") +@Validated @Tag(name = "Platform user administration / 平台用户管理") public class PlatformUserManagementController { private final PlatformUserManagementService service; @@ -27,15 +31,20 @@ public class PlatformUserManagementController { @GetMapping @PreAuthorize("hasAuthority('platform:user:list')") - @Operation(summary = "统一查询平台及企业租户用户") - public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user) { + @Operation(summary = "分页查询平台内部用户") + public ApiResponse> list( + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status, + @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); - return ApiResponse.success(service.list()); + return ApiResponse.success(service.page(page, size, keyword, status)); } @GetMapping("/options") @PreAuthorize("hasAuthority('platform:user:list')") - @Operation(summary = "查询用户管理可选租户和角色") + @Operation(summary = "查询平台内部用户可选平台角色") public ApiResponse options(@AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.options()); @@ -43,7 +52,7 @@ public class PlatformUserManagementController { @PostMapping @PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')") - @Operation(summary = "新增平台或企业租户用户") + @Operation(summary = "新增平台内部用户") public ApiResponse create(@Valid @RequestBody CreateUserRequest request, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); @@ -52,7 +61,7 @@ public class PlatformUserManagementController { @PutMapping("/{membershipId:\\d+}") @PreAuthorize("hasAuthority('platform:user:edit')") - @Operation(summary = "编辑用户资料、租户、角色、状态和密码") + @Operation(summary = "编辑平台内部用户资料和角色") public ApiResponse update(@PathVariable Long membershipId, @Valid @RequestBody UpdateUserRequest request, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index a974a3a39..7c28c5dd7 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -9,7 +9,7 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; -/** 平台统一用户管理契约,覆盖平台账号与企业租户账号。 */ +/** Admin 平台内部用户管理契约;tenantId 保留用于兼容既有请求,但只能为 0。 */ public final class PlatformUserManagementDtos { private PlatformUserManagementDtos() { } diff --git a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java index 577a38d27..f17e7f99c 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.baiyue.iam.repository; +import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembershipRole; import com.baiyue.iam.entity.IamUser; @@ -8,7 +9,9 @@ import com.baiyue.iam.mapper.IamMembershipRoleMapper; import com.baiyue.iam.mapper.IamUserMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; import java.util.List; @@ -30,9 +33,27 @@ public class MemberRepository { return membershipMapper.selectList(new LambdaQueryWrapper() .eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt)); } - public List findAll() { - return membershipMapper.selectList(new LambdaQueryWrapper() - .orderByDesc(IamMembership::getJoinedAt)); + public PageResult pageByTenant(long tenantId, long page, long size, + String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId) + .eq(StringUtils.hasText(status), IamMembership::getStatus, status) + .orderByDesc(IamMembership::getJoinedAt); + if (StringUtils.hasText(keyword)) { + String normalizedKeyword = keyword.trim(); + List matchingUserIds = userMapper.selectList(new LambdaQueryWrapper() + .select(IamUser::getId) + .in(IamUser::getUserType, "INTERNAL", "PLATFORM") + .and(query -> query.like(IamUser::getDisplayName, normalizedKeyword) + .or().like(IamUser::getPhone, normalizedKeyword) + .or().like(IamUser::getEmail, normalizedKeyword))) + .stream().map(IamUser::getId).toList(); + wrapper.and(query -> { + query.like(IamMembership::getMemberNo, normalizedKeyword); + if (!matchingUserIds.isEmpty()) query.or().in(IamMembership::getUserId, matchingUserIds); + }); + } + return PageResult.from(membershipMapper.selectPage(new Page<>(page, size), wrapper)); } public IamMembership findById(Long id) { return membershipMapper.selectById(id); } public IamMembership findByTenantAndId(long tenantId, Long id) { diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 7507f3c20..2ae33bedc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -2,17 +2,16 @@ package com.baiyue.iam.service; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamRole; -import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; -import com.baiyue.iam.repository.TenantRepository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; @@ -31,45 +30,41 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; -/** 平台管理员跨租户维护登录用户;凭据仍只由 auth-service 保存。 */ +/** 平台管理员维护内部登录用户;凭据仍只由 auth-service 保存。 */ @Service public class PlatformUserManagementService { private static final long PLATFORM_TENANT_ID = 0L; private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class); private final MemberRepository members; - private final TenantRepository tenants; private final IamRoleMapper roleMapper; private final AuthAccountClient authClient; private final String internalToken; - public PlatformUserManagementService(MemberRepository members, TenantRepository tenants, - IamRoleMapper roleMapper, AuthAccountClient authClient, + public PlatformUserManagementService(MemberRepository members, IamRoleMapper roleMapper, + AuthAccountClient authClient, @Value("${baiyue.security.internal-service-token}") String internalToken) { this.members = members; - this.tenants = tenants; this.roleMapper = roleMapper; this.authClient = authClient; this.internalToken = internalToken; } @Transactional(readOnly = true) - public List list() { - Map tenantMap = tenants.listMerchants().stream() - .collect(Collectors.toMap(IamTenant::getId, Function.identity())); - Map roleMap = allRoles().stream() + public PageResult page(long page, long size, String keyword, String status) { + PageResult source = members.pageByTenant( + PLATFORM_TENANT_ID, page, size, keyword, status); + Map roleMap = platformRoles().stream() .collect(Collectors.toMap(IamRole::getId, Function.identity())); - return members.findAll().stream().map(membership -> response(membership, tenantMap, roleMap)).toList(); + return new PageResult<>(source.records().stream() + .map(membership -> response(membership, roleMap)).toList(), + source.page(), source.size(), source.total(), source.pages()); } @Transactional(readOnly = true) public UserOptions options() { - List tenantOptions = new java.util.ArrayList<>(); - tenantOptions.add(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE")); - tenants.listMerchants().stream() - .map(value -> new TenantOption(value.getId(), value.getTenantName(), value.getStatus())) - .forEach(tenantOptions::add); - List roleOptions = allRoles().stream() + List tenantOptions = List.of(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE")); + List roleOptions = platformRoles().stream() .map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus())) .toList(); return new UserOptions(List.copyOf(tenantOptions), roleOptions); @@ -80,7 +75,7 @@ public class PlatformUserManagementService { resourceId = "#request.username", description = "平台新增用户", captureAfter = false) public UserResponse create(CreateUserRequest request) { long tenantId = request.tenantId(); - validateTenant(tenantId); + requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); @@ -122,11 +117,11 @@ public class PlatformUserManagementService { @BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false) public UserResponse update(Long membershipId, UpdateUserRequest request, Set operatorPermissions) { - IamMembership membership = required(membershipId); + IamMembership membership = requiredPlatformMembership(membershipId); IamUser user = requiredUser(membership.getUserId()); long oldTenantId = membership.getTenantId(); long newTenantId = request.tenantId(); - validateTenant(newTenantId); + requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) @@ -170,7 +165,7 @@ public class PlatformUserManagementService { @BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "平台启停用户", captureAfter = false) public UserResponse changeStatus(Long membershipId, ChangeStatusRequest request, Long operatorMembershipId) { - IamMembership membership = required(membershipId); + IamMembership membership = requiredPlatformMembership(membershipId); if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) { throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户"); } @@ -200,39 +195,35 @@ public class PlatformUserManagementService { @BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false) public void resetPassword(Long membershipId, ResetPasswordRequest request) { - IamUser user = requiredUser(required(membershipId).getUserId()); + IamUser user = requiredUser(requiredPlatformMembership(membershipId).getUserId()); call(() -> authClient.reset(internalToken, user.getAuthUserId(), new AuthAccountClient.PasswordRequest(request.newPassword()))); } private UserResponse response(IamMembership membership) { - Map tenantMap = tenants.listMerchants().stream() - .collect(Collectors.toMap(IamTenant::getId, Function.identity())); - Map roleMap = allRoles().stream() + Map roleMap = platformRoles().stream() .collect(Collectors.toMap(IamRole::getId, Function.identity())); - return response(membership, tenantMap, roleMap); + return response(membership, roleMap); } - private UserResponse response(IamMembership membership, Map tenantMap, - Map roleMap) { + private UserResponse response(IamMembership membership, Map roleMap) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary account = account(user.getAuthUserId()); List roleIds = members.findRoleIds(membership.getTenantId(), membership.getId()); List roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull) .map(IamRole::getRoleName).toList(); - String tenantName = membership.getTenantId() == PLATFORM_TENANT_ID ? "平台" - : tenantMap.containsKey(membership.getTenantId()) - ? tenantMap.get(membership.getTenantId()).getTenantName() : "未知租户"; + String tenantName = "平台"; return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); } - private List allRoles() { + private List platformRoles() { return roleMapper.selectList(new LambdaQueryWrapper() - .in(IamRole::getAppCode, "PLATFORM", "MERCHANT") - .orderByAsc(IamRole::getTenantId).orderByAsc(IamRole::getRoleName)); + .eq(IamRole::getTenantId, PLATFORM_TENANT_ID) + .eq(IamRole::getAppCode, "PLATFORM") + .orderByAsc(IamRole::getRoleName)); } private List validateRoles(long tenantId, List requested) { @@ -257,11 +248,9 @@ public class PlatformUserManagementService { .anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode())); } - private void validateTenant(long tenantId) { - if (tenantId == PLATFORM_TENANT_ID) return; - IamTenant tenant = tenants.findTenant(tenantId); - if (tenant == null || !"MERCHANT".equals(tenant.getTenantType()) || !"ACTIVE".equals(tenant.getStatus())) { - throw new BizException(ResultCode.BAD_REQUEST, "只能选择正常状态的企业租户"); + private void requirePlatformTenant(long tenantId) { + if (tenantId != PLATFORM_TENANT_ID) { + throw new BizException(ResultCode.FORBIDDEN, "内部用户只能属于平台租户"); } } @@ -271,6 +260,14 @@ public class PlatformUserManagementService { return membership; } + private IamMembership requiredPlatformMembership(Long membershipId) { + IamMembership membership = required(membershipId); + if (membership.getTenantId() != PLATFORM_TENANT_ID) { + throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在"); + } + return membership; + } + private IamUser requiredUser(Long userId) { IamUser user = members.findUser(userId); if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在"); @@ -296,7 +293,7 @@ public class PlatformUserManagementService { try { call(() -> authClient.compensate(internalToken, authUserId)); } catch (RuntimeException compensationFailure) { - log.error("统一用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + log.error("内部用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); } @@ -311,7 +308,7 @@ public class PlatformUserManagementService { if (profileChanged) call(() -> authClient.profile(internalToken, authUserId, new AuthAccountClient.ProfileRequest(oldAccount.displayName()))); } catch (RuntimeException compensationFailure) { - log.error("统一用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + log.error("内部用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 418260627..7ecc1c6f2 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -210,13 +210,22 @@ paths: /api/platform/v1/iam/users: get: tags: [IAM Resources / IAM 资源] - summary: 统一查询平台及企业租户用户 + summary: 分页查询平台内部用户 + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, schema: {type: string}, description: 匹配姓名、手机号、邮箱或成员编号} + - {name: status, in: query, schema: {type: string, enum: [ACTIVE, DISABLED]}} responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 仅返回 tenant_id=0 的平台内部用户分页 + content: + application/json: + schema: {$ref: '#/components/schemas/PlatformManagedUserPageApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [IAM Resources / IAM 资源] - summary: 新增平台或企业租户用户 + summary: 新增平台内部用户 requestBody: required: true content: @@ -228,7 +237,7 @@ paths: /api/platform/v1/iam/users/options: get: tags: [IAM Resources / IAM 资源] - summary: 查询用户管理可选租户和角色 + summary: 查询平台租户和 PLATFORM 角色选项 responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} @@ -237,7 +246,7 @@ paths: - {$ref: '#/components/parameters/MembershipId'} put: tags: [IAM Resources / IAM 资源] - summary: 编辑用户资料、所属租户及角色 + summary: 编辑平台内部用户资料及角色 requestBody: required: true content: @@ -251,7 +260,7 @@ paths: - {$ref: '#/components/parameters/MembershipId'} put: tags: [IAM Resources / IAM 资源] - summary: 启用或停用平台及企业租户用户 + summary: 启用或停用平台内部用户 requestBody: required: true content: @@ -265,7 +274,7 @@ paths: - {$ref: '#/components/parameters/MembershipId'} put: tags: [IAM Resources / IAM 资源] - summary: 重置平台及企业租户用户密码 + summary: 重置平台内部用户密码 requestBody: required: true content: @@ -748,6 +757,20 @@ components: - type: object properties: data: {$ref: '#/components/schemas/InternalUserResponse'} + PlatformManagedUserPageApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/PlatformManagedUserResponse'}} + page: {type: integer, minimum: 1} + size: {type: integer, minimum: 1} + total: {type: integer, minimum: 0} + pages: {type: integer, minimum: 0} TenantResponse: type: object required: [id, tenantCode, tenantName, status, servicePackage] @@ -883,7 +906,7 @@ components: displayName: {type: string, maxLength: 64} phone: {type: string, pattern: '^1\\d{10}$'} email: {type: string, format: email, maxLength: 128} - tenantId: {type: integer, format: int64} + tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} @@ -894,7 +917,7 @@ components: displayName: {type: string, maxLength: 64} phone: {type: string, pattern: '^1\\d{10}$'} email: {type: string, format: email, maxLength: 128} - tenantId: {type: integer, format: int64} + tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} CreateInternalUserRequest: type: object @@ -948,6 +971,26 @@ components: memberNo: {type: string} status: {type: string, enum: [ACTIVE, DISABLED]} roleIds: {type: array, items: {type: integer, format: int64}} + PlatformManagedUserResponse: + type: object + required: [membershipId, userId, authUserId, username, displayName, tenantId, tenantName, memberType, status, roleIds, roleNames, protectedAccount] + properties: + membershipId: {type: string, pattern: '^\d+$'} + userId: {type: string, pattern: '^\d+$'} + authUserId: {type: string, pattern: '^\d+$'} + username: {type: string} + displayName: {type: string} + phone: {type: string} + email: {type: string, format: email} + tenantId: {type: string, enum: ['0']} + tenantName: {type: string, enum: [平台]} + memberType: {type: string, enum: [EMPLOYEE]} + memberNo: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + roleIds: {type: array, items: {type: string, pattern: '^\d+$'}} + roleNames: {type: array, items: {type: string}} + lastLoginAt: {type: string, format: date-time, nullable: true} + protectedAccount: {type: boolean} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java index 59d7b5a7a..3b528e33b 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -37,6 +37,7 @@ class PlatformInternalUserContractTest { @Test void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception { Method list = PlatformUserManagementController.class.getMethod("list", + long.class, long.class, String.class, String.class, com.baiyue.common.security.AuthenticatedUser.class); Method create = PlatformUserManagementController.class.getMethod("create", com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class, diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 25518be11..7b3294511 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -1,19 +1,17 @@ package com.baiyue.iam.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamRole; -import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; -import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import java.time.LocalDateTime; import java.util.List; @@ -25,110 +23,89 @@ import static org.mockito.Mockito.*; class PlatformUserManagementServiceTest { private final MemberRepository members = mock(MemberRepository.class); - private final TenantRepository tenants = mock(TenantRepository.class); private final IamRoleMapper roles = mock(IamRoleMapper.class); private final AuthAccountClient auth = mock(AuthAccountClient.class); private final PlatformUserManagementService service = - new PlatformUserManagementService(members, tenants, roles, auth, "internal-token"); - private IamTenant merchant; - private IamRole merchantRole; + new PlatformUserManagementService(members, roles, auth, "internal-token"); + private IamRole platformRole; private IamMembership membership; private IamUser user; @BeforeEach void setUp() { - merchant = new IamTenant(); - merchant.setId(10001L); merchant.setTenantType("MERCHANT"); merchant.setTenantName("华远贸易"); - merchant.setStatus("ACTIVE"); - merchantRole = new IamRole(); - merchantRole.setId(40001L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT"); - merchantRole.setRoleName("租户管理员"); merchantRole.setStatus("ACTIVE"); + platformRole = new IamRole(); + platformRole.setId(40001L); platformRole.setTenantId(0L); platformRole.setAppCode("PLATFORM"); + platformRole.setRoleName("平台运营"); platformRole.setStatus("ACTIVE"); membership = new IamMembership(); - membership.setId(30L); membership.setTenantId(10001L); membership.setUserId(20L); - membership.setMemberType("EMPLOYEE"); membership.setMemberNo("MEMBER-30"); membership.setStatus("ACTIVE"); + membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L); + membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30"); membership.setStatus("ACTIVE"); user = new IamUser(); - user.setId(20L); user.setAuthUserId(10L); user.setUserType("ENTERPRISE"); + user.setId(20L); user.setAuthUserId(10L); user.setUserType("INTERNAL"); user.setDisplayName("张伟"); user.setPhone("13800138000"); user.setStatus("ACTIVE"); - when(tenants.findTenant(10001L)).thenReturn(merchant); - when(tenants.listMerchants()).thenReturn(List.of(merchant)); - when(roles.selectById(40001L)).thenReturn(merchantRole); - when(roles.selectList(any())).thenReturn(List.of(merchantRole)); + when(roles.selectById(40001L)).thenReturn(platformRole); + when(roles.selectList(any())).thenReturn(List.of(platformRole)); when(members.findById(30L)).thenReturn(membership); when(members.findUser(20L)).thenReturn(user); - when(members.findRoleIds(10001L, 30L)).thenReturn(List.of(40001L)); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L)); when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", - LocalDateTime.of(2026, 6, 1, 10, 30), 10001L, 30L, "MERCHANT"))); + LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM"))); } @Test - void listReturnsTenantRoleAndLastLoginForMerchantUser() { - when(members.findAll()).thenReturn(List.of(membership)); + void pageReturnsOnlyRepositoryScopedPlatformUsersWithMetadata() { + when(members.pageByTenant(0L, 2, 10, "张", "ACTIVE")) + .thenReturn(new PageResult<>(List.of(membership), 2, 10, 21, 3)); - UserResponse response = service.list().get(0); + PageResult result = service.page(2, 10, "张", "ACTIVE"); + UserResponse response = result.records().get(0); assertEquals("zhangwei", response.username()); - assertEquals("华远贸易", response.tenantName()); - assertEquals(List.of("租户管理员"), response.roleNames()); + assertEquals("平台", response.tenantName()); + assertEquals(List.of("平台运营"), response.roleNames()); assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt()); + assertEquals(21, result.total()); + verify(members).pageByTenant(0L, 2, 10, "张", "ACTIVE"); } @Test - void createMerchantUserCreatesDisabledMerchantAccountAndMembership() { - when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( - new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED"))); - when(auth.summary(anyString(), anyLong())).thenReturn(ApiResponse.success( - new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED"))); - when(members.findUser(anyLong())).thenReturn(user); + void createRejectsMerchantTenantBeforeCreatingAuthAccount() { CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", "new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED"); - service.create(request); - - ArgumentCaptor account = - ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); - verify(auth).create(eq("internal-token"), account.capture()); - assertEquals("MERCHANT", account.getValue().appCode()); - assertEquals("DISABLED", account.getValue().accountStatus()); - verify(members).insertUser(argThat(value -> "ENTERPRISE".equals(value.getUserType()))); - verify(members).insert(argThat(value -> value.getTenantId() == 10001L - && "DISABLED".equals(value.getStatus()))); + assertThrows(BizException.class, () -> service.create(request)); + verify(auth, never()).create(anyString(), any()); } @Test - void tenantMoveUpdatesAuthScopeAndRolesWhenOperatorCanGrantRoles() { - IamTenant target = new IamTenant(); - target.setId(10002L); target.setTenantType("MERCHANT"); target.setTenantName("深圳速达"); target.setStatus("ACTIVE"); - IamRole targetRole = new IamRole(); - targetRole.setId(40002L); targetRole.setTenantId(10002L); targetRole.setAppCode("MERCHANT"); - targetRole.setRoleName("操作员"); targetRole.setStatus("ACTIVE"); - when(tenants.findTenant(10002L)).thenReturn(target); - when(tenants.listMerchants()).thenReturn(List.of(merchant, target)); - when(roles.selectById(40002L)).thenReturn(targetRole); - when(roles.selectList(any())).thenReturn(List.of(merchantRole, targetRole)); - when(auth.scope(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null)); - when(auth.profile(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null)); + void optionsContainOnlyPlatformTenantAndPlatformRoles() { + UserOptions result = service.options(); + + assertEquals(List.of(new TenantOption(0L, "平台", "ACTIVE")), result.tenants()); + assertEquals(1, result.roles().size()); + assertEquals(0L, result.roles().get(0).tenantId()); + verifyNoInteractions(members); + } + + @Test + void updateRejectsMovingPlatformUserToMerchantTenant() { UpdateUserRequest request = new UpdateUserRequest("张伟新", "13800138000", null, - 10002L, List.of(40002L)); + 10002L, List.of(40001L)); - UserResponse response = service.update(30L, request, Set.of("platform:user:grant-role")); - - verify(auth).scope(eq("internal-token"), eq(10L), - argThat(value -> value.tenantId() == 10002L && "MERCHANT".equals(value.appCode()))); - verify(members).replaceRoles(10001L, 30L, List.of()); - verify(members).replaceRoles(10002L, 30L, List.of(40002L)); - assertEquals(10002L, response.tenantId()); + assertThrows(BizException.class, + () -> service.update(30L, request, Set.of("platform:user:grant-role"))); + verify(auth, never()).scope(anyString(), anyLong(), any()); } @Test void tenantOrRoleChangeRequiresGrantRolePermission() { IamRole otherRole = new IamRole(); - otherRole.setId(40003L); otherRole.setTenantId(10001L); otherRole.setAppCode("MERCHANT"); + otherRole.setId(40003L); otherRole.setTenantId(0L); otherRole.setAppCode("PLATFORM"); otherRole.setRoleName("操作员"); otherRole.setStatus("ACTIVE"); when(roles.selectById(40003L)).thenReturn(otherRole); UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null, - 10001L, List.of(40003L)); + 0L, List.of(40003L)); assertThrows(BizException.class, () -> service.update(30L, request, Set.of())); verify(auth, never()).scope(anyString(), anyLong(), any()); @@ -143,7 +120,6 @@ class PlatformUserManagementServiceTest { @Test void builtInPlatformAdministratorCannotBeDisabled() { - membership.setTenantId(0L); IamRole platformAdmin = new IamRole(); platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM"); platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员"); @@ -155,4 +131,16 @@ class PlatformUserManagementServiceTest { () -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L)); verify(auth, never()).status(anyString(), anyLong(), any()); } + + @Test + void merchantMembershipCannotBeChangedThroughInternalUserEndpoints() { + membership.setTenantId(10001L); + + assertThrows(BizException.class, + () -> service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L)); + assertThrows(BizException.class, + () -> service.resetPassword(30L, new ResetPasswordRequest("Password2"))); + verify(auth, never()).status(anyString(), anyLong(), any()); + verify(auth, never()).reset(anyString(), anyLong(), any()); + } }