暂存提交

This commit is contained in:
2026-07-31 14:57:09 +08:00
parent 7a19e2f096
commit f8804fbc52
53 changed files with 3771 additions and 0 deletions
@@ -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(); }
}