This commit is contained in:
2026-08-25 15:08:34 +08:00
parent e815abcbd7
commit 44198866d9
64 changed files with 1382 additions and 360 deletions
+5 -1
View File
@@ -12,10 +12,12 @@
| 目标 | 入口 |
|---|---|
| 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` |
| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` |
| 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` |
| 平台用户 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java` |
| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` |
| 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` |
| 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` |
| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../overseas-security-service/` |
| 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` |
| File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
@@ -26,7 +28,9 @@
- 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。
- 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。
- 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。
- 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。
- 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。
## 全量验收导航
@@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
import com.baiyue.iam.service.MemberService;
import com.baiyue.iam.model.CustomerDimensionInitializationStatus;
import com.baiyue.iam.dto.PlatformCustomerMemberDtos;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -77,6 +78,24 @@ public class PlatformTenantController {
return ApiResponse.success(service.merchant(tenantId));
}
@PostMapping("/tenants/{tenantId:\\d+}/dimension-initialization/retry")
@PreAuthorize("hasAuthority('platform:customer:edit')")
@Operation(summary = "重试客户默认尺寸初始化")
public ApiResponse<TenantResponse> retryDimensionInitialization(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.retryDimensionInitialization(tenantId));
}
@GetMapping("/tenants/{tenantId:\\d+}/dimension-initialization")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询客户默认尺寸初始化诊断")
public ApiResponse<CustomerDimensionInitializationStatus> dimensionInitializationStatus(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.dimensionInitializationStatus(tenantId));
}
@GetMapping("/tenants/contact-phone-availability")
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
@Operation(summary = "校验客户联系电话唯一性")
@@ -20,7 +20,8 @@ public final class OnboardingDtos {
String address
) { }
public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { }
public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus,
String dimensionInitializationStatus) { }
public record QualificationSubmissionRequest(
@NotNull Long tenantId,
@@ -133,6 +133,7 @@ public final class TenantDtos {
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
String dimensionInitializationStatus,
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
String contactPhone, String contactEmail, String contactWechat,
String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType,
@@ -22,6 +22,7 @@ public class IamTenant extends BaseEntity {
private String tenantName;
private String tenantType;
private String status;
private String dimensionInitializationStatus;
private String legalName;
private String legalRepresentative;
private String unifiedSocialCreditCode;
@@ -0,0 +1,13 @@
package com.baiyue.iam.model;
import java.time.LocalDateTime;
public record CustomerDimensionInitializationStatus(
long tenantId,
String status,
int attemptCount,
LocalDateTime nextRetryAt,
String lastErrorCode,
String lastErrorMessage,
LocalDateTime updatedAt
) { }
@@ -0,0 +1,12 @@
package com.baiyue.iam.model;
public record CustomerDimensionInitializationTask(
long tenantId,
long operatorId,
String initializationKey,
String action,
String status,
int attemptCount,
int taskVersion) {
}
@@ -0,0 +1,123 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.model.CustomerDimensionInitializationTask;
import com.baiyue.iam.model.CustomerDimensionInitializationStatus;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public class CustomerDimensionInitializationRepository {
@Resource private JdbcTemplate jdbc;
public void enqueue(long tenantId, long operatorId, String initializationKey) {
jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id,"
+ "initialization_key,action,status,attempt_count,next_retry_at,task_version) "
+ "VALUES(?,?,?,'INITIALIZE','PENDING',0,NOW(3),0)",
tenantId, operatorId, initializationKey);
}
public void requestCompensation(long tenantId, long operatorId, String initializationKey) {
int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET operator_id=?,"
+ "initialization_key=?,action='COMPENSATE',status='PENDING',attempt_count=0,"
+ "next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL,last_error_code=NULL,"
+ "last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=?",
operatorId, initializationKey, tenantId);
if (updated == 0) {
jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id,"
+ "initialization_key,action,status,attempt_count,next_retry_at,task_version) "
+ "VALUES(?,?,?,'COMPENSATE','PENDING',0,NOW(3),0)",
tenantId, operatorId, initializationKey);
}
}
public boolean manualRetry(long tenantId) {
return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET action='INITIALIZE',"
+ "status='PENDING',attempt_count=0,next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL,"
+ "last_error_code=NULL,last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) "
+ "WHERE tenant_id=? AND status='FAILED'", tenantId) == 1;
}
public Optional<CustomerDimensionInitializationStatus> findStatus(long tenantId) {
List<CustomerDimensionInitializationStatus> rows = jdbc.query(
"SELECT tenant_id,status,attempt_count,next_retry_at,last_error_code,last_error_message,updated_at "
+ "FROM iam_customer_dimension_initialization_task WHERE tenant_id=?",
(rs, row) -> new CustomerDimensionInitializationStatus(
rs.getLong("tenant_id"), rs.getString("status"), rs.getInt("attempt_count"),
rs.getTimestamp("next_retry_at").toLocalDateTime(), rs.getString("last_error_code"),
rs.getString("last_error_message"), rs.getTimestamp("updated_at").toLocalDateTime()), tenantId);
return rows.stream().findFirst();
}
public void recoverExpiredLeases() {
jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='PENDING',lease_owner=NULL,"
+ "lease_until=NULL,next_retry_at=NOW(3),task_version=task_version+1,updated_at=NOW(3) "
+ "WHERE status='SENDING' AND lease_until<NOW(3)");
}
public List<Long> candidates(int limit) {
return jdbc.queryForList("SELECT tenant_id FROM iam_customer_dimension_initialization_task "
+ "WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY next_retry_at,tenant_id LIMIT "
+ Math.max(1, Math.min(limit, 200)), Long.class);
}
@Transactional
public CustomerDimensionInitializationTask claim(long tenantId, String workerId, LocalDateTime now) {
int claimed = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='SENDING',"
+ "lease_owner=?,lease_until=?,task_version=task_version+1,updated_at=? "
+ "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, tenantId, now);
if (claimed != 1) return null;
return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status,"
+ "attempt_count,task_version FROM iam_customer_dimension_initialization_task "
+ "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?",
(rs, row) -> new CustomerDimensionInitializationTask(
rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"),
rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"),
rs.getInt("task_version")), tenantId, workerId);
}
@Transactional
public boolean markReady(CustomerDimensionInitializationTask task, String workerId, Long configId) {
int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='READY',"
+ "remote_config_id=?,lease_owner=NULL,lease_until=NULL,last_error_code=NULL,"
+ "last_error_message=NULL,completed_at=NOW(3),updated_at=NOW(3) "
+ "WHERE tenant_id=? AND action='INITIALIZE' AND status='SENDING' AND lease_owner=? AND task_version=?",
configId, task.tenantId(), workerId, task.taskVersion());
if (updated == 1) {
jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='READY',updated_at=NOW(3) "
+ "WHERE id=? AND deleted=0", task.tenantId());
}
return updated == 1;
}
public boolean markCancelled(CustomerDimensionInitializationTask task, String workerId) {
return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='CANCELLED',"
+ "lease_owner=NULL,lease_until=NULL,last_error_code=NULL,last_error_message=NULL,"
+ "completed_at=NOW(3),updated_at=NOW(3) WHERE tenant_id=? AND action='COMPENSATE' "
+ "AND status='SENDING' AND lease_owner=? AND task_version=?",
task.tenantId(), workerId, task.taskVersion()) == 1;
}
@Transactional
public boolean recordFailure(CustomerDimensionInitializationTask task, String workerId, int nextAttempt,
LocalDateTime nextRetryAt, String code, String message, boolean exhausted) {
String status = exhausted ? "FAILED" : "PENDING";
int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status=?,attempt_count=?,"
+ "next_retry_at=?,lease_owner=NULL,lease_until=NULL,last_error_code=?,last_error_message=?,"
+ "task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=? AND action=? "
+ "AND status='SENDING' AND lease_owner=? AND task_version=?",
status, nextAttempt, nextRetryAt, code, message, task.tenantId(), task.action(), workerId,
task.taskVersion());
if (updated == 1 && exhausted && "INITIALIZE".equals(task.action())) {
jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='FAILED',updated_at=NOW(3) "
+ "WHERE id=? AND deleted=0", task.tenantId());
}
return updated == 1;
}
}
@@ -0,0 +1,99 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.iam.client.CustomerDimensionRuleClient;
import com.baiyue.iam.model.CustomerDimensionInitializationTask;
import com.baiyue.iam.repository.CustomerDimensionInitializationRepository;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
@Component
@Slf4j
public class CustomerDimensionInitializationRelay {
private static final Set<String> PERMANENT_CODES = Set.of("A0400", "A0403", "A0404", "A0409", "A0422");
@Resource private CustomerDimensionInitializationRepository repository;
@Resource private CustomerDimensionRuleClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.customer-dimension-initialization.max-attempts:12}") private int maxAttempts;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.customer-dimension-initialization.fixed-delay-ms:5000}")
public void relay() {
repository.recoverExpiredLeases();
for (Long tenantId : repository.candidates(50)) deliver(tenantId);
}
void deliver(long tenantId) {
CustomerDimensionInitializationTask task = repository.claim(tenantId, workerId, LocalDateTime.now());
if (task == null) return;
try {
if ("COMPENSATE".equals(task.action())) {
ApiResponse<Void> response = client.compensate(
internalToken, task.tenantId(), task.initializationKey());
requireSuccess(response, false);
repository.markCancelled(task, workerId);
return;
}
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
internalToken, new CustomerDimensionRuleClient.InitializationRequest(
task.tenantId(), task.operatorId(), task.initializationKey()));
requireSuccess(response, true);
if (response.data() == null || response.data().configId() == null) {
throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false);
}
repository.markReady(task, workerId, response.data().configId());
} catch (Exception failure) {
int nextAttempt = task.attemptCount() + 1;
boolean permanent = failure instanceof DeliveryFailure delivery && delivery.permanent;
boolean exhausted = permanent || nextAttempt >= maxAttempts;
String code = failure instanceof DeliveryFailure delivery ? delivery.code : "S0503";
String message = safeMessage(failure);
repository.recordFailure(task, workerId, nextAttempt,
LocalDateTime.now().plusSeconds(backoffSeconds(nextAttempt)), code, message, exhausted);
log.warn("客户默认尺寸后台初始化失败 tenantId={}, action={}, attempt={}, exhausted={}, code={}, reason={}",
tenantId, task.action(), nextAttempt, exhausted, code, message);
}
}
private static void requireSuccess(ApiResponse<?> response, boolean requireData) {
if (response != null && response.successful() && (!requireData || response.data() != null)) return;
String code = response == null || response.code() == null ? "S0503" : response.code();
String message = response == null || response.message() == null || response.message().isBlank()
? "Firstleg 服务未确认客户默认尺寸初始化" : response.message();
throw new DeliveryFailure(code, message, PERMANENT_CODES.contains(code));
}
private static long backoffSeconds(int attempt) {
return switch (Math.min(Math.max(attempt, 1), 5)) {
case 1 -> 10;
case 2 -> 30;
case 3 -> 120;
case 4 -> 600;
default -> 1800;
};
}
private static String safeMessage(Exception failure) {
String value = failure.getMessage();
if (value == null || value.isBlank()) value = failure.getClass().getSimpleName();
value = value.replaceAll("[\\r\\n\\t]+", " ");
return value.length() > 1000 ? value.substring(0, 1000) : value;
}
private static final class DeliveryFailure extends RuntimeException {
private final String code;
private final boolean permanent;
private DeliveryFailure(String code, String message, boolean permanent) {
super(message);
this.code = code;
this.permanent = permanent;
}
}
}
@@ -1,119 +1,50 @@
package com.baiyue.iam.service;
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.CustomerDimensionRuleClient;
import com.baiyue.iam.repository.CustomerDimensionInitializationRepository;
import com.baiyue.iam.model.CustomerDimensionInitializationStatus;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Provisions four default box-size dimensions and compensates them when customer creation rolls back. */
/** Owns IAM's durable customer-dimension initialization intent; remote delivery is always background. */
@Service
public class CustomerDimensionRuleInitializationService {
private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class);
private static final int MAX_ATTEMPTS = 3;
private static final String KEY_PREFIX = "iam-customer-create:";
@Resource
private CustomerDimensionRuleClient client;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@Resource private CustomerDimensionInitializationRepository repository;
public InitializationResult initialize(long tenantId, long operatorId) {
String initializationKey = initializationKey(tenantId);
RuntimeException lastFailure = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
internalToken,
new CustomerDimensionRuleClient.InitializationRequest(
tenantId, operatorId, initializationKey));
if (response != null && response.successful() && response.data() != null) {
InitializationResult result = new InitializationResult(
response.data().createdByThisInitialization(), initializationKey);
registerRollbackCompensation(tenantId, result);
return result;
}
lastFailure = unavailable(response);
log.warn("客户4条默认箱规尺寸初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}",
tenantId, attempt, response == null ? null : response.code(),
response == null ? null : response.requestId(),
response == null ? null : response.traceId());
} catch (RuntimeException failure) {
lastFailure = failure;
log.warn("客户4条默认箱规尺寸初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure);
}
}
compensateAfterUncertainInitialization(tenantId, initializationKey);
throw unavailable(lastFailure);
repository.enqueue(tenantId, operatorId, initializationKey);
return new InitializationResult(false, initializationKey, "PENDING");
}
/** Used when Auth compensates a committed client registration. */
public void compensateRegistration(long tenantId) {
compensateRequired(tenantId, initializationKey(tenantId));
repository.requestCompensation(tenantId, 0L, initializationKey(tenantId));
}
private void registerRollbackCompensation(long tenantId, InitializationResult result) {
if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status != STATUS_ROLLED_BACK) return;
try {
compensateRequired(tenantId, result.initializationKey());
} catch (RuntimeException compensationFailure) {
log.error("客户创建事务回滚后4条默认箱规尺寸补偿失败,tenantId={}, initializationKey={}",
tenantId, result.initializationKey(), compensationFailure);
}
}
});
}
private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) {
try {
compensateRequired(tenantId, initializationKey);
} catch (RuntimeException compensationFailure) {
log.error("客户4条默认箱规尺寸初始化结果不确定且补偿失败,tenantId={}, initializationKey={}",
tenantId, initializationKey, compensationFailure);
public void retry(long tenantId) {
if (!repository.manualRetry(tenantId)) {
throw new BizException(ResultCode.CONFLICT, "默认尺寸初始化当前不可人工重试");
}
}
private void compensateRequired(long tenantId, String initializationKey) {
RuntimeException lastFailure = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ApiResponse<Void> response = client.compensate(internalToken, tenantId, initializationKey);
if (response != null && response.successful()) return;
lastFailure = unavailable(response);
} catch (RuntimeException failure) {
lastFailure = failure;
}
public CustomerDimensionInitializationStatus status(long tenantId) {
return repository.findStatus(tenantId).orElseThrow(
() -> new BizException(ResultCode.NOT_FOUND, "默认尺寸初始化任务不存在"));
}
public void requireReady(String status) {
if (!"READY".equals(status)) {
throw new BizException(ResultCode.CONFLICT,
"客户默认尺寸尚未就绪,当前状态: " + (status == null ? "PENDING" : status));
}
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"客户4条默认箱规尺寸补偿失败,请根据客户ID修复数据一致性: " + tenantId);
}
private static BizException unavailable(ApiResponse<?> response) {
String detail = response == null || response.message() == null || response.message().isBlank()
? "Firstleg 服务未返回有效响应" : response.message();
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "4条默认箱规尺寸初始化失败: " + detail);
}
private static BizException unavailable(RuntimeException cause) {
String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank()
? "Firstleg 服务暂时不可用" : cause.getMessage();
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
"4条默认箱规尺寸初始化失败,已重试 3 次: " + detail);
}
private static String initializationKey(long tenantId) {
return KEY_PREFIX + tenantId;
}
public record InitializationResult(boolean created, String initializationKey) { }
public record InitializationResult(boolean created, String initializationKey, String status) { }
}
@@ -69,6 +69,7 @@ public class MerchantOnboardingService {
tenant.setTenantName(request.companyName());
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
tenant.setLegalName(request.companyName());
tenant.setLegalRepresentative(request.legalPerson());
tenant.setContactName(request.displayName());
@@ -122,7 +123,8 @@ public class MerchantOnboardingService {
}
dimensionRuleInitialization.initialize(tenantId, request.authUserId());
overseasStorageFeeInitialization.initialize(tenantId, request.authUserId(), tenant.getTenantName());
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus(),
tenant.getDimensionInitializationStatus());
}
/**
@@ -16,6 +16,7 @@ import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.model.CustomerDimensionInitializationStatus;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
@@ -169,6 +170,7 @@ public class TenantService {
tenant.setTenantName(request.tenantName());
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setUnifiedSocialCreditCode(null);
@@ -271,6 +273,9 @@ public class TenantService {
if (tenant == null) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
}
if (approve) {
dimensionRuleInitialization.requireReady(tenant.getDimensionInitializationStatus());
}
var initializationContext = new TenantApprovalInitializer.ApprovalContext(
tenant, reviewId, reviewerId, comment);
@@ -321,6 +326,10 @@ public class TenantService {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"Enterprise information must be approved before ordering");
}
if (!"READY".equals(tenant.getDimensionInitializationStatus())) {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"Customer default dimensions are not ready");
}
if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"All required enterprise qualifications must be approved");
@@ -338,6 +347,22 @@ public class TenantService {
return response(requireMerchant(tenantId));
}
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_DIMENSION_INITIALIZATION_RETRY", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台重试客户默认尺寸初始化", captureBefore = false)
public TenantResponse retryDimensionInitialization(long tenantId) {
IamTenant tenant = requireMerchantForUpdate(tenantId);
dimensionRuleInitialization.retry(tenantId);
tenant.setDimensionInitializationStatus("PENDING");
repository.saveTenant(tenant);
return response(tenant);
}
public CustomerDimensionInitializationStatus dimensionInitializationStatus(long tenantId) {
requireMerchant(tenantId);
return dimensionRuleInitialization.status(tenantId);
}
public boolean contactPhoneAvailable(String contactPhone, Long tenantId) {
String normalized = normalizeContactPhone(contactPhone);
return tenantId == null
@@ -505,6 +530,7 @@ public class TenantService {
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
tenant.getDimensionInitializationStatus(),
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(),
@@ -527,6 +553,7 @@ public class TenantService {
private TenantResponse summaryResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
response.dimensionInitializationStatus(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(),
@@ -537,6 +564,7 @@ public class TenantService {
private TenantResponse merchantVisibleResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
response.dimensionInitializationStatus(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
response.paymentTermDays(), response.enterpriseType(), response.establishedDate(),
@@ -0,0 +1,33 @@
SET NAMES utf8mb4;
USE baiyue_iam;
SET @customer_dimension_status_ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='iam_tenant' AND column_name='dimension_initialization_status'),
'SELECT 1',
'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status'));
PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl;
EXECUTE customer_dimension_status_stmt;
DEALLOCATE PREPARE customer_dimension_status_stmt;
CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID',
operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0',
initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}',
action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间',
lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者',
lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间',
last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码',
last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证',
remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID',
task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间',
completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间',
PRIMARY KEY (tenant_id),
KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until),
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态';
+23
View File
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档',
dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败',
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
@@ -42,6 +43,28 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
KEY idx_iam_tenant_status (status, created_at)
) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键';
CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID',
operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0',
initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}',
action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间',
lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者',
lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间',
last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码',
last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证',
remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID',
task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间',
completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间',
PRIMARY KEY (tenant_id),
KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until),
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态';
CREATE TABLE IF NOT EXISTS iam_tenant_review (
id BIGINT NOT NULL COMMENT '审核记录ID',
tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键',
+46 -2
View File
@@ -59,7 +59,7 @@ paths:
post:
tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业客户草稿
description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。
description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Customer creation succeeds after IAM commits; default dimensions initialize reliably in background and are exposed as `PENDING/READY/FAILED`. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据;IAM 提交即代表客户创建成功,默认尺寸在后台可靠初始化并显式返回 PENDING/READY/FAILED。
requestBody:
required: true
content:
@@ -123,6 +123,38 @@ paths:
$ref: '#/components/schemas/PlatformCustomerMembersApiResponse'
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/tenants/{tenantId}/dimension-initialization:
parameters:
- {$ref: '#/components/parameters/TenantId'}
get:
tags: [Platform Tenant / 平台租户]
summary: 查询客户默认尺寸初始化诊断
description: 返回任务状态、尝试次数、下次重试时间和最后失败原因;不会把远端失败转换为空数据或成功。
responses:
'200':
description: 初始化诊断查询成功
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/CustomerDimensionInitializationStatus'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/tenants/{tenantId}/dimension-initialization/retry:
parameters:
- {$ref: '#/components/parameters/TenantId'}
post:
tags: [Platform Tenant / 平台租户]
summary: 人工重试失败的客户默认尺寸初始化
description: 只允许 FAILED 任务重新进入 PENDING;稳定 initializationKey 不变。
responses:
'200': {$ref: '#/components/responses/TenantSuccess'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/tenants/{tenantId}/submit-review:
parameters:
- {$ref: '#/components/parameters/TenantId'}
@@ -1085,12 +1117,13 @@ components:
pages: {type: integer, minimum: 0}
TenantResponse:
type: object
required: [id, tenantCode, tenantName, status, servicePackage]
required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage]
properties:
id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
tenantCode: {type: string}
tenantName: {type: string}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
unifiedSocialCreditCode: {type: string}
@@ -1109,6 +1142,17 @@ components:
internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见}
createdAt: {type: string, format: date-time, description: 入驻时间}
approvedAt: {type: string, format: date-time, description: 审批通过时间}
CustomerDimensionInitializationStatus:
type: object
required: [tenantId, status, attemptCount, nextRetryAt, updatedAt]
properties:
tenantId: {type: string, pattern: '^\d+$'}
status: {type: string, enum: [PENDING, SENDING, READY, FAILED, CANCELLED]}
attemptCount: {type: integer, minimum: 0}
nextRetryAt: {type: string, format: date-time}
lastErrorCode: {type: string, nullable: true}
lastErrorMessage: {type: string, nullable: true, description: 已清洗且截断的最后失败诊断}
updatedAt: {type: string, format: date-time}
FinanceCustomerSummary:
type: object
required: [id, tenantCode, tenantName, status]
@@ -0,0 +1,104 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.iam.client.CustomerDimensionRuleClient;
import com.baiyue.iam.model.CustomerDimensionInitializationTask;
import com.baiyue.iam.repository.CustomerDimensionInitializationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerDimensionInitializationRelayTest {
@Mock private CustomerDimensionInitializationRepository repository;
@Mock private CustomerDimensionRuleClient client;
@InjectMocks private CustomerDimensionInitializationRelay relay;
@BeforeEach
void configure() {
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
ReflectionTestUtils.setField(relay, "maxAttempts", 12);
}
@Test
void successfulBackgroundInitializationMarksTenantReady() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
relay.deliver(10001L);
verify(repository).markReady(task, workerId(), 91L);
verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
anyString(), any(Boolean.class));
}
@Test
void transientFailureStaysPendingWithDiagnosticAndBackoff() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("read timeout"));
relay.deliver(10001L);
ArgumentCaptor<Boolean> exhausted = ArgumentCaptor.forClass(Boolean.class);
verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
anyString(), exhausted.capture());
assertFalse(exhausted.getValue());
}
@Test
void permanentRemoteRejectionBecomesFailedWithoutDefaultSuccess() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.failure(
ResultCode.CONFLICT, "invalid customer", "request-1", "trace-1"));
relay.deliver(10001L);
ArgumentCaptor<Boolean> exhausted = ArgumentCaptor.forClass(Boolean.class);
verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
anyString(), exhausted.capture());
assertTrue(exhausted.getValue());
verify(repository, never()).markReady(any(), anyString(), any());
}
@Test
void compensationIsAlsoDurableAndUsesTheStableKey() {
CustomerDimensionInitializationTask task = task("COMPENSATE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.compensate("internal-token", 10001L, "iam-customer-create:10001"))
.thenReturn(ApiResponse.success(null));
relay.deliver(10001L);
verify(repository).markCancelled(task, workerId());
}
private CustomerDimensionInitializationTask task(String action, int attempts) {
return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001",
action, "SENDING", attempts, 3);
}
private String workerId() {
return (String) ReflectionTestUtils.getField(relay, "workerId");
}
}
@@ -1,138 +1,61 @@
package com.baiyue.iam.service;
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.CustomerDimensionRuleClient;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import com.baiyue.iam.repository.CustomerDimensionInitializationRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@ExtendWith(MockitoExtension.class)
class CustomerDimensionRuleInitializationServiceTest {
@MockBean private CustomerDimensionRuleClient client;
@Resource private CustomerDimensionRuleInitializationService service;
@AfterEach
void clearSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Mock private CustomerDimensionInitializationRepository repository;
@InjectMocks private CustomerDimensionRuleInitializationService service;
@Test
void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("timeout"))
.thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE,
"temporarily unavailable", "request-1", "trace-1"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
void customerCreationOnlyPersistsBackgroundIntentWithStableKey() {
var result = service.initialize(10001L, 9001L);
assertFalse(result.created());
ArgumentCaptor<CustomerDimensionRuleClient.InitializationRequest> requests =
ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class);
verify(client, times(3)).initialize(anyString(), requests.capture());
assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001",
"iam-customer-create:10001"),
requests.getAllValues().stream().map(
CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList());
assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream()
.map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList());
assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream()
.map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList());
assertEquals("PENDING", result.status());
assertEquals("iam-customer-create:10001", result.initializationKey());
verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001");
}
@Test
void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() {
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout"));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode());
verify(client, times(3)).initialize(anyString(), any());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
var result = service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
assertTrue(result.created());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
TransactionSynchronizationManager.initSynchronization();
service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(client, never()).compensate(anyString(), anyLong(), anyString());
}
@Test
void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("response timeout"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(client, times(2)).initialize(anyString(), any());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() {
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
void committedRegistrationCompensationUsesTheSameStableKey() {
service.compensateRegistration(10001L);
verify(repository).requestCompensation(10001L, 0L, "iam-customer-create:10001");
}
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
@Test
void failedTaskCanBeManuallyRequeued() {
when(repository.manualRetry(10001L)).thenReturn(true);
service.retry(10001L);
verify(repository).manualRetry(10001L);
}
@Test
void nonFailedTaskCannotBeSilentlyRequeued() {
when(repository.manualRetry(10001L)).thenReturn(false);
BizException failure = assertThrows(BizException.class, () -> service.retry(10001L));
assertEquals(ResultCode.CONFLICT, failure.getResultCode());
}
@Test
void dimensionsDependentFlowFailsClosedUntilReady() {
BizException pending = assertThrows(BizException.class, () -> service.requireReady("PENDING"));
BizException failed = assertThrows(BizException.class, () -> service.requireReady("FAILED"));
assertEquals(ResultCode.CONFLICT, pending.getResultCode());
assertEquals(ResultCode.CONFLICT, failed.getResultCode());
service.requireReady("READY");
}
}