Files
baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java
T

264 lines
13 KiB
Java

package com.baiyue.auth.service;
import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import com.baiyue.common.audit.BusinessAudit;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
@Service
public class AccountManagementService {
@Resource private AuthAccountRepository repository;
@Resource private PasswordEncoder passwordEncoder;
@Resource private TokenSessionStore tokenSessionStore;
public boolean usernameAvailable(String username) {
return username != null && !username.isBlank() && !repository.existsByUsername(username.trim());
}
@Transactional
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
public void changePassword(Long accountId, ChangePasswordRequest request) {
AuthAccount account = required(accountId);
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
}
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
}
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
}
setPasswordAndRevoke(account, request.newPassword(), false);
}
@Transactional
@BusinessAudit(event = "PASSWORD_FORCED_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "完成管理员重置后的强制改密", captureAfter = false)
public void forcedChangePassword(Long accountId, ForcedPasswordChangeRequest request) {
AuthAccount account = required(accountId);
if (!Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
}
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与当前密码相同");
}
setPasswordAndRevoke(account, request.newPassword(), false);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT",
resourceId = "#request.accountId", description = "创建认证账号")
public AccountSummary create(CreateAccountRequest request) {
String appCode = request.appCode() == null ? "MERCHANT"
: request.appCode().trim().toUpperCase(Locale.ROOT);
validateScope(appCode, request.tenantId());
// Locking a missing key takes an InnoDB gap lock and can deadlock
// unrelated account inserts. The unique indexes arbitrate creation.
AuthAccount existing = repository.findCurrentById(request.accountId());
if (existing != null) {
if (sameIdentity(existing, request, appCode)) return summary(existing);
throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用");
}
if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
}
if (request.phone() != null && repository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
AuthAccount account = new AuthAccount();
account.setId(request.accountId());
account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName());
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
account.setFailedLoginCount(0);
account.setTokenVersion(1);
account.setDefaultTenantId(request.tenantId());
account.setOperatorTenantId(request.tenantId());
account.setDefaultMembershipId(request.membershipId());
account.setDefaultAppCode(appCode);
account.setDeleted(0);
try {
repository.insert(account);
} catch (DuplicateKeyException duplicate) {
AuthAccount winner = repository.findById(request.accountId());
if (winner != null && sameIdentity(winner, request, appCode)) return summary(winner);
throw new BizException(ResultCode.CONFLICT, "认证账号标识或登录账号已被使用");
}
return summary(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_STATUS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改账号状态", captureAfter = false)
public void updateStatus(Long accountId, UpdateAccountStatusRequest request) {
if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) {
throw new BizException(ResultCode.BAD_REQUEST, "账号状态只允许 ACTIVE 或 DISABLED");
}
AuthAccount account = required(accountId);
account.setAccountStatus(request.status());
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_SESSIONS_REVOKE", resourceType = "ACCOUNT_BATCH",
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
captureBefore = false, captureAfter = false)
public void revokeSessions(Set<Long> accountIds) {
accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId)));
}
@Transactional
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步账号显示名称", captureAfter = false)
public void updateProfile(Long accountId, UpdateAccountProfileRequest request) {
AuthAccount account = required(accountId);
if (Objects.equals(account.getDisplayName(), request.displayName())) return;
account.setDisplayName(request.displayName());
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "修改本人用户名和手机号", captureAfter = false)
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
AuthAccount account = required(accountId);
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
}
String username = request.username().trim();
String phone = request.phone().trim();
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
return summary(account);
}
account.setUsername(username);
account.setPhone(phone);
bumpVersion(account);
return summary(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步受管账号手机号", captureAfter = false)
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
AuthAccount account = required(accountId);
String phone = request.phone() == null ? null : request.phone().trim();
if (Objects.equals(account.getPhone(), phone)) return;
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
account.setPhone(phone);
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
setPasswordAndRevoke(required(accountId), request.newPassword(), forceChangePassword);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_ACCOUNT_ARCHIVE", resourceType = "TENANT", resourceId = "#tenantId",
description = "归档客户并撤销客户账号", captureBefore = false, captureAfter = false)
public void archiveTenant(Long tenantId) {
for (AuthAccount account : repository.findMerchantByTenant(tenantId)) {
account.setAccountStatus("DISABLED");
account.setPasswordChangeRequired(false);
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
repository.save(account);
tokenSessionStore.revokeAll(account.getId());
}
}
@Transactional
@BusinessAudit(event = "ACCOUNT_SCOPE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "修改账号所属租户", captureAfter = false)
public void updateScope(Long accountId, UpdateAccountScopeRequest request) {
String appCode = request.appCode().trim().toUpperCase(Locale.ROOT);
validateScope(appCode, request.tenantId());
AuthAccount account = required(accountId);
account.setDefaultTenantId(request.tenantId());
account.setOperatorTenantId(request.tenantId());
account.setDefaultMembershipId(request.membershipId());
account.setDefaultAppCode(appCode);
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_COMPENSATE_DELETE", resourceType = "ACCOUNT",
resourceId = "#accountId", description = "补偿删除认证账号", captureAfter = false)
public void compensateDelete(Long accountId) {
AuthAccount account = repository.findById(accountId);
if (account != null) {
account.setAccountStatus("DISABLED");
account.setDeleted(1);
bumpVersion(account);
}
}
public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
private void setPasswordAndRevoke(AuthAccount account, String password, boolean forceChangePassword) {
account.setPasswordHash(passwordEncoder.encode(password));
account.setFailedLoginCount(0);
account.setLockedUntil(null);
account.setPasswordChangeRequired(forceChangePassword);
bumpVersion(account);
}
private void bumpVersion(AuthAccount account) {
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
repository.save(account);
tokenSessionStore.revokeAll(account.getId());
}
private AuthAccount required(Long id) {
AuthAccount account = repository.findById(id);
if (account == null) throw new BizException(ResultCode.NOT_FOUND, "账号不存在");
return account;
}
private AccountSummary summary(AuthAccount a) {
return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
}
private void validateScope(String appCode, Long tenantId) {
if (!"MERCHANT".equals(appCode) && !"PLATFORM".equals(appCode)) {
throw new BizException(ResultCode.BAD_REQUEST, "应用端只能是 MERCHANT 或 PLATFORM");
}
if (("PLATFORM".equals(appCode) && tenantId != 0L)
|| ("MERCHANT".equals(appCode) && tenantId <= 0L)) {
throw new BizException(ResultCode.BAD_REQUEST, "账号应用端与租户范围不匹配");
}
}
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username())
&& Objects.equals(account.getPhone(), request.phone())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
&& Objects.equals(account.getDefaultAppCode(), appCode)
&& !Integer.valueOf(1).equals(account.getDeleted());
}
}