This commit is contained in:
2026-08-04 09:05:28 +08:00
parent fdb1e8af1c
commit 3afcff320a
116 changed files with 6749 additions and 749 deletions
@@ -1,6 +1,8 @@
package com.baiyue.auth.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -14,6 +16,11 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request);
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId);
record OnboardingRequest(Long authUserId, String displayName, String companyName,
String legalPerson, String legalPhone, String contactEmail,
String countryCode, String address) { }
@@ -47,7 +47,7 @@ public class AuthController {
@SecurityRequirements
@PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册",
description = "Create a login account and an enterprise profile awaiting one business-license upload.")
description = "Create a login account and an enterprise profile awaiting four required qualification files.")
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request));
}
@@ -11,13 +11,21 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.atomic.AtomicBoolean;
@Service
public class MerchantRegistrationService {
private static final Logger LOGGER = LoggerFactory.getLogger(MerchantRegistrationService.class);
@Resource private AuthAccountRepository accountRepository;
@Resource private PasswordEncoder passwordEncoder;
@Resource private IamOnboardingClient onboardingClient;
@@ -31,6 +39,9 @@ public class MerchantRegistrationService {
throw new BizException(ResultCode.CONFLICT, "Username already exists");
}
long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
AtomicBoolean compensationCompleted = new AtomicBoolean(false);
registerRollbackCompensation(accountId, compensationCompleted);
IamOnboardingClient.OnboardingResponse onboarding;
try {
var response = onboardingClient.register(internalToken,
@@ -51,17 +62,26 @@ public class MerchantRegistrationService {
}
onboarding = response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
"IAM registration service is unavailable");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
} catch (BizException exception) {
compensateOnboarding(accountId, exception, compensationCompleted);
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
"IAM registration service is unavailable");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
}
AuthAccount account = new AuthAccount();
account.setId(accountId);
account.setUsername(request.username());
account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE");
account.setFailedLoginCount(0);
@@ -71,8 +91,61 @@ public class MerchantRegistrationService {
account.setDefaultMembershipId(onboarding.membershipId());
account.setDefaultAppCode("MERCHANT");
account.setDeleted(0);
accountRepository.insert(account);
try {
accountRepository.insert(account);
} catch (DuplicateKeyException exception) {
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
} catch (RuntimeException exception) {
compensateOnboarding(accountId, exception, compensationCompleted);
throw exception;
}
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE");
onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
}
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_COMMITTED || compensationCompleted.get()) {
return;
}
IllegalStateException rollbackFailure = new IllegalStateException(
"Auth transaction rolled back after IAM onboarding");
if (!compensateOnboarding(accountId, rollbackFailure, compensationCompleted)) {
LOGGER.error("IAM registration compensation failed after Auth rollback, authUserId={}",
accountId, rollbackFailure);
}
}
});
}
private boolean compensateOnboarding(long accountId, RuntimeException registrationFailure,
AtomicBoolean compensationCompleted) {
if (compensationCompleted.get()) {
return true;
}
try {
var response = onboardingClient.compensate(internalToken, accountId);
if (response != null && response.successful()) {
compensationCompleted.set(true);
return true;
}
{
String code = response == null ? "empty-response" : response.code();
String message = response == null ? "IAM compensation returned no response" : response.message();
registrationFailure.addSuppressed(new IllegalStateException(
"IAM registration compensation failed: code=" + code + ", message=" + message));
}
} catch (RuntimeException compensationFailure) {
registrationFailure.addSuppressed(compensationFailure);
}
return false;
}
}
@@ -0,0 +1,116 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.IamOnboardingClient;
import com.baiyue.auth.dto.MerchantRegisterRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantRegistrationServiceTest {
@Test
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
PasswordEncoder encoder = mock(PasswordEncoder.class);
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
MerchantRegistrationService service = new MerchantRegistrationService();
ReflectionTestUtils.setField(service, "accountRepository", accounts);
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", "张三",
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
ArgumentCaptor<IamOnboardingClient.OnboardingRequest> onboardingRequest =
ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
ArgumentCaptor<AuthAccount> storedAccount = ArgumentCaptor.forClass(AuthAccount.class);
verify(onboarding).register(eq("internal-token"), onboardingRequest.capture());
verify(accounts).insert(storedAccount.capture());
assertEquals(onboardingRequest.getValue().authUserId(), storedAccount.getValue().getId());
assertEquals(result.accountId(), storedAccount.getValue().getId());
assertEquals(10001L, storedAccount.getValue().getDefaultTenantId());
assertEquals(10001L, storedAccount.getValue().getOperatorTenantId());
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
}
@Test
void failedAuthInsertCompensatesTheIamRegistration() {
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
PasswordEncoder encoder = mock(PasswordEncoder.class);
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
MerchantRegistrationService service = new MerchantRegistrationService();
ReflectionTestUtils.setField(service, "accountRepository", accounts);
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
assertThrows(IllegalStateException.class, () -> service.register(new MerchantRegisterRequest(
"failed_owner", "Password123", "注册人", "测试企业", "张三",
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
ArgumentCaptor<IamOnboardingClient.OnboardingRequest> onboardingRequest =
ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
ArgumentCaptor<Long> compensationUserId = ArgumentCaptor.forClass(Long.class);
verify(onboarding).register(eq("internal-token"), onboardingRequest.capture());
verify(onboarding).compensate(eq("internal-token"), compensationUserId.capture());
assertEquals(onboardingRequest.getValue().authUserId(), compensationUserId.getValue());
}
@Test
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
PasswordEncoder encoder = mock(PasswordEncoder.class);
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
MerchantRegistrationService service = new MerchantRegistrationService();
ReflectionTestUtils.setField(service, "accountRepository", accounts);
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
try {
service.register(new MerchantRegisterRequest(
"rollback_owner", "Password123", "注册人", "测试企业", "张三",
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
var synchronizations = TransactionSynchronizationManager.getSynchronizations();
assertEquals(1, synchronizations.size());
synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
verify(onboarding).compensate(eq("internal-token"), any());
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
}