优化
This commit is contained in:
+5
-1
@@ -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) {
|
||||
}
|
||||
|
||||
+123
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-89
@@ -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客户默认尺寸可靠后台初始化任务及诊断状态';
|
||||
@@ -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和数据隔离键',
|
||||
|
||||
@@ -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]
|
||||
|
||||
+104
@@ -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");
|
||||
}
|
||||
}
|
||||
+36
-113
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user