暂存提交
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.service.AuthorizationSnapshotService;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 当前登录成员的有效权限查询接口。
|
||||
*
|
||||
* <p>此接口只要求调用方已通过对应应用端的 JWT 鉴权;不能以角色管理权限作为
|
||||
* 初始化登录会话的前置条件。权限从 IAM 当前有效成员、角色和权限资源实时计算,
|
||||
* 因此角色被停用或权限被回收后,下一次查询会立即生效。</p>
|
||||
*/
|
||||
@RestController
|
||||
@Tag(name = "Authorization / 当前用户授权", description = "Returns current member effective permission codes.")
|
||||
public class AuthorizationController {
|
||||
@Resource
|
||||
private AuthorizationSnapshotService authorizationSnapshotService;
|
||||
|
||||
@GetMapping("/api/{app}/v1/iam/authorization/permissions")
|
||||
@Operation(summary = "Get current effective permissions / 查询当前用户有效权限",
|
||||
description = "Does not require role-management permissions. / 不依赖角色管理权限。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Permissions returned"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Wrong application scope or inactive identity")
|
||||
})
|
||||
public ApiResponse<Set<String>> permissions(@PathVariable String app,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
String appCode = appCode(app);
|
||||
TenantGuard.tenant(user, appCode);
|
||||
return ApiResponse.success(authorizationSnapshotService.currentPermissions(user, appCode));
|
||||
}
|
||||
|
||||
private String appCode(String app) {
|
||||
return switch (app) {
|
||||
case "platform" -> "PLATFORM";
|
||||
case "merchant" -> "MERCHANT";
|
||||
default -> throw new BizException(ResultCode.BAD_REQUEST, "Unsupported application scope / 不支持的应用端");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.iam.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 平台内部用户公开接口的数据契约,不向前端暴露密码摘要等认证敏感字段。 */
|
||||
public final class PlatformInternalUserDtos {
|
||||
private PlatformInternalUserDtos() { }
|
||||
|
||||
/**
|
||||
* 同时兼容历史的 authUserId 关联模式和完整账号创建模式。
|
||||
* authUserId 有值时关联已有认证账号;否则 username、password 必填并由 IAM 编排 Auth 创建账号。
|
||||
*/
|
||||
public record CreateInternalUserRequest(
|
||||
Long authUserId,
|
||||
String userType,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@Size(max = 32) String phone,
|
||||
@Email @Size(max = 128) String email,
|
||||
@Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
||||
@Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||
String password,
|
||||
@Size(max = 64) String memberNo,
|
||||
List<Long> roleIds) { }
|
||||
|
||||
public record UpdateInternalUserRequest(
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@Size(max = 32) String phone,
|
||||
@Email @Size(max = 128) String email,
|
||||
@NotBlank @Size(max = 64) String memberNo,
|
||||
@NotNull List<Long> roleIds) { }
|
||||
|
||||
public record ChangeInternalUserStatusRequest(
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
|
||||
|
||||
public record ResetInternalUserPasswordRequest(
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||
String newPassword) { }
|
||||
|
||||
public record InternalUserResponse(
|
||||
Long membershipId,
|
||||
Long userId,
|
||||
Long authUserId,
|
||||
String username,
|
||||
String displayName,
|
||||
String phone,
|
||||
String email,
|
||||
String memberType,
|
||||
String memberNo,
|
||||
String status,
|
||||
List<Long> roleIds) { }
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
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.PlatformInternalUserDtos.*;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 平台内部用户编排服务:IAM 保存人员和角色,账号凭据只由 Auth 保存。 */
|
||||
@Service
|
||||
public class PlatformInternalUserService {
|
||||
private static final long PLATFORM_TENANT_ID = 0L;
|
||||
private static final Logger log = LoggerFactory.getLogger(PlatformInternalUserService.class);
|
||||
|
||||
private final MemberRepository repository;
|
||||
private final IamRoleMapper roleMapper;
|
||||
private final AuthAccountClient authClient;
|
||||
private final String internalToken;
|
||||
|
||||
public PlatformInternalUserService(MemberRepository repository, IamRoleMapper roleMapper,
|
||||
AuthAccountClient authClient,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.repository = repository;
|
||||
this.roleMapper = roleMapper;
|
||||
this.authClient = authClient;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<InternalUserResponse> list() {
|
||||
return repository.findByTenant(PLATFORM_TENANT_ID).stream()
|
||||
.map(this::response).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_CREATE", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#request.username", description = "新增平台内部用户", captureAfter = false)
|
||||
public InternalUserResponse create(CreateInternalUserRequest request) {
|
||||
validateLegacyType(request.userType());
|
||||
List<Long> roleIds = normalizeRoles(request.roleIds());
|
||||
validateRoles(roleIds);
|
||||
|
||||
if (request.authUserId() != null) return linkExistingAccount(request, roleIds);
|
||||
if (!StringUtils.hasText(request.username()) || !StringUtils.hasText(request.password())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "新建平台用户时登录名和密码必填");
|
||||
}
|
||||
|
||||
long authUserId = IdWorker.getId();
|
||||
long userId = IdWorker.getId();
|
||||
long membershipId = IdWorker.getId();
|
||||
String memberNo = memberNo(request.memberNo(), membershipId);
|
||||
validateMemberNo(memberNo, null);
|
||||
|
||||
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
|
||||
authUserId, request.username(), request.password(), request.displayName(),
|
||||
PLATFORM_TENANT_ID, membershipId, "PLATFORM")));
|
||||
try {
|
||||
IamUser user = newUser(userId, authUserId, request.displayName(), request.phone(), request.email());
|
||||
IamMembership membership = newMembership(membershipId, userId, memberNo);
|
||||
repository.insertUser(user);
|
||||
repository.insert(membership);
|
||||
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||
return response(membership, user, request.username(), roleIds);
|
||||
} catch (RuntimeException localFailure) {
|
||||
compensateCreate(authUserId, localFailure);
|
||||
throw localFailure;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_UPDATE", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "编辑平台内部用户", captureAfter = false)
|
||||
public InternalUserResponse update(Long membershipId, UpdateInternalUserRequest request) {
|
||||
List<Long> roleIds = normalizeRoles(request.roleIds());
|
||||
validateRoles(roleIds);
|
||||
validateMemberNo(request.memberNo(), membershipId);
|
||||
IamMembership membership = required(membershipId);
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
|
||||
boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName());
|
||||
if (displayNameChanged) {
|
||||
call(() -> authClient.profile(internalToken, user.getAuthUserId(),
|
||||
new AuthAccountClient.ProfileRequest(request.displayName())));
|
||||
}
|
||||
try {
|
||||
user.setDisplayName(request.displayName());
|
||||
user.setPhone(request.phone());
|
||||
user.setEmail(request.email());
|
||||
membership.setMemberNo(request.memberNo());
|
||||
repository.saveUser(user);
|
||||
repository.save(membership);
|
||||
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||
} catch (RuntimeException localFailure) {
|
||||
if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure);
|
||||
throw localFailure;
|
||||
}
|
||||
return response(membership, user, request.displayName(), roleIds, oldAccount.username());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "启停平台内部用户", captureAfter = false)
|
||||
public InternalUserResponse changeStatus(Long membershipId, ChangeInternalUserStatusRequest request,
|
||||
Long operatorMembershipId) {
|
||||
if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户状态只允许 ACTIVE 或 DISABLED");
|
||||
}
|
||||
IamMembership membership = required(membershipId);
|
||||
if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户");
|
||||
}
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
List<Long> roleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId);
|
||||
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
|
||||
call(() -> authClient.status(internalToken, user.getAuthUserId(),
|
||||
new AuthAccountClient.StatusRequest(request.status())));
|
||||
try {
|
||||
membership.setStatus(request.status());
|
||||
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
|
||||
user.setStatus(request.status());
|
||||
repository.saveUser(user);
|
||||
repository.save(membership);
|
||||
return response(membership, user, authAccount.username(), roleIds);
|
||||
} catch (RuntimeException localFailure) {
|
||||
compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure);
|
||||
throw localFailure;
|
||||
}
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "重置平台内部用户密码", captureAfter = false)
|
||||
public void resetPassword(Long membershipId, ResetInternalUserPasswordRequest request) {
|
||||
IamMembership membership = required(membershipId);
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
call(() -> authClient.reset(internalToken, user.getAuthUserId(),
|
||||
new AuthAccountClient.PasswordRequest(request.newPassword())));
|
||||
}
|
||||
|
||||
private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List<Long> roleIds) {
|
||||
IamUser existingUser = repository.findUserByAuthId(request.authUserId());
|
||||
IamMembership existingMembership = repository.findByTenantAndAuthUser(
|
||||
PLATFORM_TENANT_ID, request.authUserId());
|
||||
if (existingMembership != null) return response(existingMembership);
|
||||
if (existingUser != null && !"INTERNAL".equals(existingUser.getUserType())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "认证账号已关联其他类型的 IAM 用户");
|
||||
}
|
||||
AuthAccountClient.AccountSummary authAccount = account(request.authUserId());
|
||||
long membershipId = IdWorker.getId();
|
||||
String memberNo = memberNo(request.memberNo(), membershipId);
|
||||
validateMemberNo(memberNo, null);
|
||||
IamUser user = existingUser == null
|
||||
? newUser(IdWorker.getId(), request.authUserId(), request.displayName(), request.phone(), request.email())
|
||||
: existingUser;
|
||||
if (existingUser == null) repository.insertUser(user);
|
||||
IamMembership membership = newMembership(membershipId, user.getId(), memberNo);
|
||||
repository.insert(membership);
|
||||
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||
return response(membership, user, authAccount.username(), roleIds);
|
||||
}
|
||||
|
||||
private InternalUserResponse response(IamMembership membership) {
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
|
||||
return response(membership, user, authAccount.username(),
|
||||
repository.findRoleIds(PLATFORM_TENANT_ID, membership.getId()));
|
||||
}
|
||||
|
||||
private InternalUserResponse response(IamMembership membership, IamUser user,
|
||||
String username, List<Long> roleIds) {
|
||||
return response(membership, user, user.getDisplayName(), roleIds, username);
|
||||
}
|
||||
|
||||
private InternalUserResponse response(IamMembership membership, IamUser user, String displayName,
|
||||
List<Long> roleIds, String username) {
|
||||
return new InternalUserResponse(membership.getId(), user.getId(), user.getAuthUserId(), username,
|
||||
displayName, user.getPhone(), user.getEmail(), membership.getMemberType(),
|
||||
membership.getMemberNo(), membership.getStatus(), List.copyOf(roleIds));
|
||||
}
|
||||
|
||||
private IamUser newUser(long userId, long authUserId, String displayName, String phone, String email) {
|
||||
IamUser user = new IamUser();
|
||||
user.setId(userId);
|
||||
user.setAuthUserId(authUserId);
|
||||
user.setUserType("INTERNAL");
|
||||
user.setDisplayName(displayName);
|
||||
user.setPhone(phone);
|
||||
user.setEmail(email);
|
||||
user.setStatus("ACTIVE");
|
||||
return user;
|
||||
}
|
||||
|
||||
private IamMembership newMembership(long membershipId, long userId, String memberNo) {
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(membershipId);
|
||||
membership.setTenantId(PLATFORM_TENANT_ID);
|
||||
membership.setUserId(userId);
|
||||
membership.setMemberType("EMPLOYEE");
|
||||
membership.setMemberNo(memberNo);
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setJoinedAt(LocalDateTime.now());
|
||||
return membership;
|
||||
}
|
||||
|
||||
private IamMembership required(Long membershipId) {
|
||||
IamMembership membership = repository.findByTenantAndId(PLATFORM_TENANT_ID, membershipId);
|
||||
if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在");
|
||||
return membership;
|
||||
}
|
||||
|
||||
private IamUser requiredUser(Long userId) {
|
||||
IamUser user = repository.findUser(userId);
|
||||
if (user == null || !"INTERNAL".equals(user.getUserType())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "平台内部用户档案不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private List<Long> normalizeRoles(List<Long> roleIds) {
|
||||
if (roleIds == null) return List.of();
|
||||
if (roleIds.stream().anyMatch(Objects::isNull)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "角色 ID 不能为空");
|
||||
}
|
||||
return List.copyOf(new LinkedHashSet<>(roleIds));
|
||||
}
|
||||
|
||||
private void validateRoles(List<Long> roleIds) {
|
||||
for (Long roleId : roleIds) {
|
||||
IamRole role = roleMapper.selectById(roleId);
|
||||
if (role == null || role.getTenantId() == null || role.getTenantId() != PLATFORM_TENANT_ID
|
||||
|| !"PLATFORM".equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMemberNo(String memberNo, Long excludedMembershipId) {
|
||||
if (repository.existsMemberNo(PLATFORM_TENANT_ID, memberNo, excludedMembershipId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "平台成员编号已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private String memberNo(String requested, long membershipId) {
|
||||
return StringUtils.hasText(requested) ? requested.trim() : "PLATFORM-" + membershipId;
|
||||
}
|
||||
|
||||
private void validateLegacyType(String userType) {
|
||||
if (StringUtils.hasText(userType)
|
||||
&& !"INTERNAL".equals(userType) && !"PLATFORM".equals(userType)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户类型必须是 INTERNAL");
|
||||
}
|
||||
}
|
||||
|
||||
private AuthAccountClient.AccountSummary account(Long authUserId) {
|
||||
return data(call(() -> authClient.summary(internalToken, authUserId)));
|
||||
}
|
||||
|
||||
private void compensateCreate(long authUserId, RuntimeException localFailure) {
|
||||
try {
|
||||
call(() -> authClient.compensate(internalToken, authUserId));
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("平台用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"平台用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private void compensateProfile(long authUserId, String oldDisplayName, RuntimeException localFailure) {
|
||||
try {
|
||||
call(() -> authClient.profile(internalToken, authUserId,
|
||||
new AuthAccountClient.ProfileRequest(oldDisplayName)));
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("平台用户编辑失败且认证资料补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"平台用户编辑未完成,认证资料补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private void compensateStatus(long authUserId, String oldStatus, RuntimeException localFailure) {
|
||||
try {
|
||||
call(() -> authClient.status(internalToken, authUserId,
|
||||
new AuthAccountClient.StatusRequest(oldStatus)));
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("平台用户状态修改失败且认证状态补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"平台用户状态未完成,认证状态补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private <T> ApiResponse<T> call(RemoteCall<T> action) {
|
||||
try {
|
||||
ApiResponse<T> result = action.call();
|
||||
if (result == null || !result.successful()) throw unavailable();
|
||||
return result;
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||
if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在");
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T data(ApiResponse<T> response) {
|
||||
if (response.data() == null) throw unavailable();
|
||||
return response.data();
|
||||
}
|
||||
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RemoteCall<T> { ApiResponse<T> call(); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class AuthorizationControllerContractTest {
|
||||
@Test
|
||||
void 当前权限接口不应依赖角色管理权限() throws Exception {
|
||||
Method method = AuthorizationController.class.getMethod("permissions", String.class,
|
||||
AuthenticatedUser.class);
|
||||
|
||||
assertEquals("/api/{app}/v1/iam/authorization/permissions",
|
||||
method.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertNull(method.getAnnotation(PreAuthorize.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class IamOpenApiContractTest {
|
||||
@Test
|
||||
void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException {
|
||||
String document;
|
||||
try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) {
|
||||
assertThat(stream).isNotNull();
|
||||
document = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:")
|
||||
.contains("/api/merchant/v1/iam/authorization/permissions:")
|
||||
.contains("PermissionCodesApiResponse:")
|
||||
.contains("TenantPageApiResponse:")
|
||||
.contains("InternalUserListApiResponse:")
|
||||
.contains("membershipId: {type: integer, format: int64}")
|
||||
.contains("roleIds: {type: array, items: {type: integer, format: int64}}")
|
||||
.contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
|
||||
|
||||
Map<String, Object> openApi = new Yaml().load(document);
|
||||
assertThat(openApi).containsEntry("openapi", "3.0.3");
|
||||
assertThat(openApi.get("paths")).isInstanceOf(Map.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PlatformInternalUserContractTest {
|
||||
|
||||
@Test
|
||||
void 内部用户列表必须校验列表权限() throws Exception {
|
||||
Method method = ProfileResourceController.class.getMethod("internalUsers",
|
||||
com.baiyue.common.security.AuthenticatedUser.class);
|
||||
|
||||
assertEquals("hasAuthority('platform:user:list')",
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("/api/platform/v1/iam/internal-users",
|
||||
method.getAnnotation(GetMapping.class).value()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台用户写接口以成员关系主键寻址() {
|
||||
assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}");
|
||||
assertMapping("changeInternalUserStatus",
|
||||
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status");
|
||||
assertMapping("resetInternalUserPassword",
|
||||
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业资料查看必须校验查看权限() throws Exception {
|
||||
Method method = MerchantTenantController.class.getMethod("profile",
|
||||
com.baiyue.common.security.AuthenticatedUser.class);
|
||||
assertEquals("hasAuthority('merchant:profile:view')",
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
private void assertMapping(String methodName, String path) {
|
||||
Method method = java.util.Arrays.stream(ProfileResourceController.class.getMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(path, method.getAnnotation(PutMapping.class).value()[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class PlatformTenantControllerContractTest {
|
||||
@Test
|
||||
void 客户分页接口声明权限分页约束和错误状态() throws Exception {
|
||||
Method method = PlatformTenantController.class.getMethod("page", long.class, long.class,
|
||||
String.class, String.class, AuthenticatedUser.class);
|
||||
|
||||
assertEquals("/tenants", method.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("hasAuthority('platform:customer:list')",
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
assertNotNull(annotation(method, 0, Min.class));
|
||||
assertNotNull(annotation(method, 1, Min.class));
|
||||
assertEquals(200, annotation(method, 1, Max.class).value());
|
||||
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
|
||||
annotation(method, 3, Pattern.class).regexp());
|
||||
|
||||
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
|
||||
.map(io.swagger.v3.oas.annotations.responses.ApiResponse::responseCode)
|
||||
.collect(Collectors.toSet());
|
||||
assertEquals(Set.of("200", "400", "401", "403", "422"), statuses);
|
||||
}
|
||||
|
||||
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
|
||||
return method.getParameters()[parameter].getAnnotation(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class QualificationUploadContractTest {
|
||||
|
||||
@Test
|
||||
void iamControllerDoesNotExposeQualificationUpload() {
|
||||
boolean exposesLegacyPost = java.util.Arrays.stream(ProfileResourceController.class.getDeclaredMethods())
|
||||
.map(method -> method.getAnnotation(PostMapping.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.flatMap(mapping -> java.util.Arrays.stream(mapping.value()))
|
||||
.anyMatch("/api/merchant/v1/iam/tenant/qualifications"::equals);
|
||||
|
||||
assertFalse(exposesLegacyPost, "IAM 商户资质接口只读,上传必须由 File 服务处理");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticOpenApiDoesNotAdvertiseLegacyQualificationPost() throws IOException {
|
||||
try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) {
|
||||
assertNotNull(input);
|
||||
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
int pathStart = yaml.indexOf(" /api/merchant/v1/iam/tenant/qualifications:");
|
||||
int nextPath = yaml.indexOf("\n /api/", pathStart + 1);
|
||||
assertTrue(pathStart >= 0 && nextPath > pathStart);
|
||||
String pathContract = yaml.substring(pathStart, nextPath);
|
||||
|
||||
assertFalse(pathContract.contains("\n post:"));
|
||||
assertFalse(yaml.contains(" QualificationRequest:"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class PlatformInternalUserServiceTest {
|
||||
private final MemberRepository repository = mock(MemberRepository.class);
|
||||
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
|
||||
private final AuthAccountClient auth = mock(AuthAccountClient.class);
|
||||
private final PlatformInternalUserService service =
|
||||
new PlatformInternalUserService(repository, roleMapper, auth, "internal-token");
|
||||
private IamMembership membership;
|
||||
private IamUser user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
membership = new IamMembership();
|
||||
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("INTERNAL");
|
||||
user.setDisplayName("平台运营"); user.setPhone("13800138000"); user.setStatus("ACTIVE");
|
||||
when(repository.findByTenantAndId(0L, 30L)).thenReturn(membership);
|
||||
when(repository.findUser(20L)).thenReturn(user);
|
||||
when(repository.findRoleIds(0L, 30L)).thenReturn(List.of(40L));
|
||||
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 列表返回成员主键联系方式和当前角色() {
|
||||
when(repository.findByTenant(0L)).thenReturn(List.of(membership));
|
||||
|
||||
InternalUserResponse result = service.list().get(0);
|
||||
|
||||
assertEquals(30L, result.membershipId());
|
||||
assertEquals("operator", result.username());
|
||||
assertEquals("13800138000", result.phone());
|
||||
assertEquals(List.of(40L), result.roleIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新建平台用户在Auth创建平台账号并写入IAM成员() {
|
||||
IamRole role = new IamRole();
|
||||
role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE");
|
||||
when(roleMapper.selectById(40L)).thenReturn(role);
|
||||
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE")));
|
||||
CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营",
|
||||
"13900139000", "operator@example.com", "new.operator", "Password1",
|
||||
"PLATFORM-NEW", List.of(40L));
|
||||
|
||||
InternalUserResponse result = service.create(request);
|
||||
|
||||
assertEquals("new.operator", result.username());
|
||||
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> captor =
|
||||
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
|
||||
verify(auth).create(eq("internal-token"), captor.capture());
|
||||
assertEquals("PLATFORM", captor.getValue().appCode());
|
||||
assertEquals(0L, captor.getValue().tenantId());
|
||||
verify(repository).insertUser(any(IamUser.class));
|
||||
verify(repository).insert(any(IamMembership.class));
|
||||
verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 本地创建失败会补偿认证账号且不记录密码() {
|
||||
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE")));
|
||||
when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null));
|
||||
doThrow(new RuntimeException("IAM 写入失败")).when(repository).insertUser(any());
|
||||
CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营",
|
||||
null, null, "new.operator", "Password1", null, List.of());
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.create(request));
|
||||
|
||||
verify(auth).compensate(eq("internal-token"), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 不能停用当前登录的平台成员() {
|
||||
assertThrows(BizException.class, () -> service.changeStatus(30L,
|
||||
new ChangeInternalUserStatusRequest("DISABLED"), 30L));
|
||||
|
||||
verify(auth, never()).status(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 状态本地写入失败时恢复Auth且成功后不再远程查询() {
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.changeStatus(30L,
|
||||
new ChangeInternalUserStatusRequest("DISABLED"), 99L));
|
||||
|
||||
verify(auth).status(eq("internal-token"), eq(10L),
|
||||
argThat(value -> "DISABLED".equals(value.status())));
|
||||
verify(auth).status(eq("internal-token"), eq(10L),
|
||||
argThat(value -> "ACTIVE".equals(value.status())));
|
||||
verify(auth, times(1)).summary("internal-token", 10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 状态补偿应恢复Auth原状态而不是IAM旧状态() {
|
||||
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED")));
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.changeStatus(30L,
|
||||
new ChangeInternalUserStatusRequest("ACTIVE"), 99L));
|
||||
|
||||
verify(auth).status(eq("internal-token"), eq(10L),
|
||||
argThat(value -> "ACTIVE".equals(value.status())));
|
||||
verify(auth).status(eq("internal-token"), eq(10L),
|
||||
argThat(value -> "DISABLED".equals(value.status())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 重置密码只转交Auth不写入IAM() {
|
||||
when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1"));
|
||||
|
||||
verify(auth).reset(eq("internal-token"), eq(10L),
|
||||
argThat(value -> "ResetPass1".equals(value.newPassword())));
|
||||
verify(repository, never()).saveUser(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TenantServiceTest {
|
||||
|
||||
@Test
|
||||
void pageMerchantsMapsTenantEntitiesToApiResponses() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L);
|
||||
tenant.setTenantCode("TEN10001");
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setStatus("ACTIVE");
|
||||
tenant.setLegalName("Example Merchant Ltd");
|
||||
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
|
||||
tenant.setContactName("Alice");
|
||||
tenant.setContactPhone("13800138000");
|
||||
tenant.setContactEmail("alice@example.com");
|
||||
when(repository.pageMerchants(1, 10, "Example", "ACTIVE"))
|
||||
.thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1));
|
||||
|
||||
var result = service.pageMerchants(1, 10, "Example", "ACTIVE");
|
||||
|
||||
assertEquals(1, result.total());
|
||||
assertEquals("TEN10001", result.records().get(0).tenantCode());
|
||||
assertEquals("Alice", result.records().get(0).contactName());
|
||||
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user