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 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()); } }