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