264 lines
13 KiB
Java
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());
|
|
}
|
|
}
|