优化
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
| `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`TenantOrderCountResponse.java`、`README-ORDER.md` |
|
||||
| `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
|
||||
| `warehouse/` | 仓库、地址、服务能力、往来单位多类型、快递公司与 XLSX 导入 | `controller/`、`PlatformPartnerQueryService.java`、`PartnerImportService.java`、`ExpressCompanyImportService.java` |
|
||||
| `file/` | 企业资质文件上传、替换、预览与鉴权 | `QualificationFileController.java`、`PlatformQualificationFileController.java`、`LocalQualificationStorage.java`;元数据仍归 IAM,File 自身表结构尚未接入 |
|
||||
| `file/` | 企业资质文件与公共导出/下载中心 | 资质:`QualificationFileController.java`、`LocalQualificationStorage.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`、`mq/`、`GenericXlsxExportRunner.java`、`ExportReconciler.java` |
|
||||
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 |
|
||||
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
|
||||
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
|
||||
@@ -56,6 +56,7 @@
|
||||
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
|
||||
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
|
||||
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
|
||||
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
|
||||
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
|
||||
|
||||
## 通用修改约束
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -14,6 +16,11 @@ public interface IamOnboardingClient {
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody OnboardingRequest request);
|
||||
|
||||
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
||||
ApiResponse<Void> compensate(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable("authUserId") Long authUserId);
|
||||
|
||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
String countryCode, String address) { }
|
||||
|
||||
@@ -47,7 +47,7 @@ public class AuthController {
|
||||
@SecurityRequirements
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
description = "Create a login account and an enterprise profile awaiting one business-license upload.")
|
||||
description = "Create a login account and an enterprise profile awaiting four required qualification files.")
|
||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||
return ApiResponse.success(registrationService.register(request));
|
||||
}
|
||||
|
||||
@@ -11,13 +11,21 @@ import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@Service
|
||||
public class MerchantRegistrationService {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(MerchantRegistrationService.class);
|
||||
@Resource private AuthAccountRepository accountRepository;
|
||||
@Resource private PasswordEncoder passwordEncoder;
|
||||
@Resource private IamOnboardingClient onboardingClient;
|
||||
@@ -31,6 +39,9 @@ public class MerchantRegistrationService {
|
||||
throw new BizException(ResultCode.CONFLICT, "Username already exists");
|
||||
}
|
||||
long accountId = IdWorker.getId();
|
||||
String passwordHash = passwordEncoder.encode(request.password());
|
||||
AtomicBoolean compensationCompleted = new AtomicBoolean(false);
|
||||
registerRollbackCompensation(accountId, compensationCompleted);
|
||||
IamOnboardingClient.OnboardingResponse onboarding;
|
||||
try {
|
||||
var response = onboardingClient.register(internalToken,
|
||||
@@ -51,17 +62,26 @@ public class MerchantRegistrationService {
|
||||
}
|
||||
onboarding = response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
|
||||
BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"IAM registration service is unavailable");
|
||||
failure.addSuppressed(exception);
|
||||
compensateOnboarding(accountId, failure, compensationCompleted);
|
||||
throw failure;
|
||||
} catch (BizException exception) {
|
||||
compensateOnboarding(accountId, exception, compensationCompleted);
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
|
||||
BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"IAM registration service is unavailable");
|
||||
failure.addSuppressed(exception);
|
||||
compensateOnboarding(accountId, failure, compensationCompleted);
|
||||
throw failure;
|
||||
}
|
||||
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(accountId);
|
||||
account.setUsername(request.username());
|
||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
account.setPasswordHash(passwordHash);
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setFailedLoginCount(0);
|
||||
@@ -71,8 +91,61 @@ public class MerchantRegistrationService {
|
||||
account.setDefaultMembershipId(onboarding.membershipId());
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setDeleted(0);
|
||||
accountRepository.insert(account);
|
||||
try {
|
||||
accountRepository.insert(account);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
|
||||
failure.addSuppressed(exception);
|
||||
compensateOnboarding(accountId, failure, compensationCompleted);
|
||||
throw failure;
|
||||
} catch (RuntimeException exception) {
|
||||
compensateOnboarding(accountId, exception, compensationCompleted);
|
||||
throw exception;
|
||||
}
|
||||
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE");
|
||||
onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
return;
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status == STATUS_COMMITTED || compensationCompleted.get()) {
|
||||
return;
|
||||
}
|
||||
IllegalStateException rollbackFailure = new IllegalStateException(
|
||||
"Auth transaction rolled back after IAM onboarding");
|
||||
if (!compensateOnboarding(accountId, rollbackFailure, compensationCompleted)) {
|
||||
LOGGER.error("IAM registration compensation failed after Auth rollback, authUserId={}",
|
||||
accountId, rollbackFailure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean compensateOnboarding(long accountId, RuntimeException registrationFailure,
|
||||
AtomicBoolean compensationCompleted) {
|
||||
if (compensationCompleted.get()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
var response = onboardingClient.compensate(internalToken, accountId);
|
||||
if (response != null && response.successful()) {
|
||||
compensationCompleted.set(true);
|
||||
return true;
|
||||
}
|
||||
{
|
||||
String code = response == null ? "empty-response" : response.code();
|
||||
String message = response == null ? "IAM compensation returned no response" : response.message();
|
||||
registrationFailure.addSuppressed(new IllegalStateException(
|
||||
"IAM registration compensation failed: code=" + code + ", message=" + message));
|
||||
}
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
registrationFailure.addSuppressed(compensationFailure);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.IamOnboardingClient;
|
||||
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MerchantRegistrationServiceTest {
|
||||
@Test
|
||||
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
|
||||
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
|
||||
MerchantRegistrationService service = new MerchantRegistrationService();
|
||||
ReflectionTestUtils.setField(service, "accountRepository", accounts);
|
||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
||||
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||
|
||||
var result = service.register(new MerchantRegisterRequest(
|
||||
"registered_owner", "Password123", "注册人", "测试企业", "张三",
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
|
||||
|
||||
ArgumentCaptor<IamOnboardingClient.OnboardingRequest> onboardingRequest =
|
||||
ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
|
||||
ArgumentCaptor<AuthAccount> storedAccount = ArgumentCaptor.forClass(AuthAccount.class);
|
||||
verify(onboarding).register(eq("internal-token"), onboardingRequest.capture());
|
||||
verify(accounts).insert(storedAccount.capture());
|
||||
|
||||
assertEquals(onboardingRequest.getValue().authUserId(), storedAccount.getValue().getId());
|
||||
assertEquals(result.accountId(), storedAccount.getValue().getId());
|
||||
assertEquals(10001L, storedAccount.getValue().getDefaultTenantId());
|
||||
assertEquals(10001L, storedAccount.getValue().getOperatorTenantId());
|
||||
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
||||
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAuthInsertCompensatesTheIamRegistration() {
|
||||
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
|
||||
MerchantRegistrationService service = new MerchantRegistrationService();
|
||||
ReflectionTestUtils.setField(service, "accountRepository", accounts);
|
||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
||||
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.register(new MerchantRegisterRequest(
|
||||
"failed_owner", "Password123", "注册人", "测试企业", "张三",
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
|
||||
|
||||
ArgumentCaptor<IamOnboardingClient.OnboardingRequest> onboardingRequest =
|
||||
ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
|
||||
ArgumentCaptor<Long> compensationUserId = ArgumentCaptor.forClass(Long.class);
|
||||
verify(onboarding).register(eq("internal-token"), onboardingRequest.capture());
|
||||
verify(onboarding).compensate(eq("internal-token"), compensationUserId.capture());
|
||||
assertEquals(onboardingRequest.getValue().authUserId(), compensationUserId.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
|
||||
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
|
||||
MerchantRegistrationService service = new MerchantRegistrationService();
|
||||
ReflectionTestUtils.setField(service, "accountRepository", accounts);
|
||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
||||
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
service.register(new MerchantRegisterRequest(
|
||||
"rollback_owner", "Password123", "注册人", "测试企业", "张三",
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
|
||||
var synchronizations = TransactionSynchronizationManager.getSynchronizations();
|
||||
assertEquals(1, synchronizations.size());
|
||||
synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
verify(onboarding).compensate(eq("internal-token"), any());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.common.core.export;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Versioned contracts shared by File Service and business export providers. */
|
||||
public final class ExportContracts {
|
||||
private ExportContracts() { }
|
||||
|
||||
public record ExportCommand(
|
||||
int schemaVersion, String eventId, String taskId, String exportType,
|
||||
int dispatchVersion, OffsetDateTime occurredAt, String traceId) { }
|
||||
|
||||
public record ExportBatchRequest(
|
||||
String taskId, String tenantId, String appCode, String requestedBy,
|
||||
int dispatchVersion, Map<String, Object> parameters, String cursor,
|
||||
String snapshotToken, int limit) { }
|
||||
|
||||
public record ExportBatchResponse(
|
||||
int schemaVersion, String schemaHash, String snapshotToken,
|
||||
List<ExportColumn> columns, List<Map<String, Object>> rows,
|
||||
String nextCursor, boolean hasMore, Long totalRows) { }
|
||||
|
||||
public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { }
|
||||
|
||||
public enum ExportColumnType { STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATETIME }
|
||||
}
|
||||
@@ -35,6 +35,10 @@
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -7,3 +7,16 @@ spring:
|
||||
|
||||
# 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。
|
||||
baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
|
||||
baiyue:
|
||||
file:
|
||||
export:
|
||||
definitions:
|
||||
CUSTOMER_LIST:
|
||||
provider-service: iam-service
|
||||
provider-path: /internal/exports/v1/CUSTOMER_LIST/batches
|
||||
required-permission: platform:customer:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
|
||||
@@ -4,3 +4,16 @@ spring.datasource:
|
||||
username: ${FILE_DB_USERNAME:root}
|
||||
password: ${FILE_DB_PASSWORD:123456}
|
||||
baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads}
|
||||
baiyue:
|
||||
file:
|
||||
export:
|
||||
definitions:
|
||||
CUSTOMER_LIST:
|
||||
provider-service: iam-service
|
||||
provider-path: /internal/exports/v1/CUSTOMER_LIST/batches
|
||||
required-permission: platform:customer:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: firstleg-${DEPLOY_ENV:-test}-rocketmq
|
||||
|
||||
services:
|
||||
rocketmq-namesrv:
|
||||
image: apache/rocketmq:5.3.1
|
||||
restart: unless-stopped
|
||||
command: sh mqnamesrv
|
||||
networks: [infra-network]
|
||||
|
||||
rocketmq-broker:
|
||||
image: apache/rocketmq:5.3.1
|
||||
restart: unless-stopped
|
||||
depends_on: [rocketmq-namesrv]
|
||||
environment:
|
||||
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
|
||||
volumes:
|
||||
- ./rocketmq:/config:ro
|
||||
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
|
||||
command: sh /config/render-broker-config.sh
|
||||
networks: [infra-network]
|
||||
|
||||
rocketmq-topic-init:
|
||||
image: apache/rocketmq:5.3.1
|
||||
depends_on: [rocketmq-broker]
|
||||
environment:
|
||||
# 必须与 File Service 完全一致;允许生产环境显式传入空字符串。
|
||||
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?MQ_TOPIC_PREFIX must be set; use test- for test and an empty value for production}
|
||||
command: >-
|
||||
sh -eu -c 'until sh mqadmin clusterList -n rocketmq-namesrv:9876 2>/dev/null
|
||||
| grep -q "BaiyueCluster"; do sleep 2; done;
|
||||
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
|
||||
-t "$${MQ_TOPIC_PREFIX}baiyue-file-export-command-v1" -r 4 -w 4;
|
||||
sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster
|
||||
-g "$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1";
|
||||
sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster
|
||||
-g "$${MQ_TOPIC_PREFIX}baiyue-file-export-dlq-handler-v1";
|
||||
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
|
||||
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1" -r 1 -w 1 -p 6'
|
||||
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
|
||||
restart: on-failure
|
||||
networks: [infra-network]
|
||||
|
||||
networks:
|
||||
infra-network:
|
||||
external: true
|
||||
name: ${INFRA_NETWORK:-firstleg-test-infra-network}
|
||||
@@ -124,6 +124,14 @@ services:
|
||||
FILE_DB_USERNAME: ${FILE_DB_USERNAME}
|
||||
FILE_DB_PASSWORD: ${FILE_DB_PASSWORD}
|
||||
FILE_STORAGE_ROOT: /data/files
|
||||
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
|
||||
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER}
|
||||
# 必须显式设置:测试填 test-,生产填空字符串;使用 ? 而不是 :?,允许生产空值。
|
||||
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?MQ_TOPIC_PREFIX must be set; use test- for test and an empty value for production}
|
||||
FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false}
|
||||
FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4}
|
||||
FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}
|
||||
FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}
|
||||
|
||||
print:
|
||||
<<: *common
|
||||
|
||||
Vendored
+5
@@ -5,3 +5,8 @@ MYSQL_DATABASE=baiyue
|
||||
MYSQL_USER=baiyue
|
||||
MYSQL_PASSWORD=CHANGE_ME_TEST_MYSQL_PASSWORD
|
||||
REDIS_PASSWORD=CHANGE_ME_TEST_REDIS_PASSWORD
|
||||
# RocketMQ 与 File Service 位于同一 Docker 网络,Broker 向客户端公布服务名。
|
||||
ROCKETMQ_BROKER_IP=rocketmq-broker
|
||||
# 测试环境必须显式使用 test-;生产环境在对应凭据文件中显式设置为空字符串。
|
||||
MQ_TOPIC_PREFIX=test-
|
||||
ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
brokerClusterName=BaiyueCluster
|
||||
brokerName=baiyue-file-export-broker
|
||||
brokerId=0
|
||||
brokerRole=ASYNC_MASTER
|
||||
flushDiskType=ASYNC_FLUSH
|
||||
deleteWhen=04
|
||||
fileReservedTime=72
|
||||
autoCreateTopicEnable=false
|
||||
autoCreateSubscriptionGroup=false
|
||||
listenPort=10911
|
||||
brokerIP1=__BROKER_IP__
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "${ROCKETMQ_BROKER_IP:-}" in
|
||||
""|localhost|127.0.0.1|__BROKER_IP__|*'${'*)
|
||||
echo "ROCKETMQ_BROKER_IP must be an explicit client-reachable address" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
sed "s/__BROKER_IP__/${ROCKETMQ_BROKER_IP}/g" /config/broker.conf.template > /tmp/broker.conf
|
||||
if grep -Eq '__BROKER_IP__|\$\{' /tmp/broker.conf; then
|
||||
echo "broker.conf still contains an unresolved placeholder" >&2
|
||||
grep -nE '__BROKER_IP__|\$\{' /tmp/broker.conf >&2
|
||||
exit 1
|
||||
fi
|
||||
exec sh mqbroker -n rocketmq-namesrv:9876 -c /tmp/broker.conf
|
||||
@@ -32,8 +32,15 @@ CALL regression_assert_table('iam_permission');
|
||||
CALL regression_assert_table('iam_role');
|
||||
CALL regression_assert_table('iam_role_permission');
|
||||
CALL regression_assert_table('iam_tenant');
|
||||
CALL regression_assert_table('iam_tenant_review');
|
||||
CALL regression_assert_table('iam_tenant_qualification');
|
||||
CALL regression_assert_table('iam_membership');
|
||||
DROP PROCEDURE regression_assert_table;
|
||||
|
||||
ALTER TABLE iam_tenant
|
||||
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED'
|
||||
COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED';
|
||||
|
||||
SET @iam_permission_built_in_sql = IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -48,6 +55,50 @@ PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql;
|
||||
EXECUTE iam_permission_built_in_stmt;
|
||||
DEALLOCATE PREPARE iam_permission_built_in_stmt;
|
||||
|
||||
-- 审核通过自动开户依赖全局企业管理员模板。通过自然键幂等补齐,避免存量环境
|
||||
-- 已存在同编码但 ID 不同的角色时,把权限错误授予固定 ID。
|
||||
INSERT INTO iam_role (
|
||||
id, tenant_id, app_code, role_code, role_name, role_type, status,
|
||||
description, created_by, updated_by, deleted, version
|
||||
) VALUES (
|
||||
4000100, 0, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE',
|
||||
'企业审核通过后自动创建的管理员角色模板', 1000001, 1000001, 0, 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE',
|
||||
description = VALUES(description), deleted = 0;
|
||||
|
||||
-- 从一期商户权限基线复制全局模板;不创建演示租户或业务数据。
|
||||
INSERT INTO iam_permission (
|
||||
id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted
|
||||
)
|
||||
SELECT permission.id + 100000, 0, permission.app_code, permission.permission_type,
|
||||
CASE WHEN permission.parent_id = 0 THEN 0 ELSE permission.parent_id + 100000 END,
|
||||
permission.permission_code, permission.permission_name, permission.route_path,
|
||||
permission.http_method, permission.sort_no, 'ACTIVE', 1, 0
|
||||
FROM iam_permission permission
|
||||
WHERE permission.tenant_id = 10001 AND permission.app_code = 'MERCHANT'
|
||||
AND permission.permission_type IN ('MENU', 'BUTTON')
|
||||
AND permission.deleted = 0
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
http_method = VALUES(http_method), sort_no = VALUES(sort_no),
|
||||
status = 'ACTIVE', built_in = 1, deleted = 0;
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT owner_role.id, permission.id, 0
|
||||
FROM iam_role owner_role
|
||||
JOIN iam_permission permission
|
||||
ON permission.tenant_id = 0 AND permission.app_code = 'MERCHANT'
|
||||
AND permission.permission_type IN ('MENU', 'BUTTON')
|
||||
AND permission.status = 'ACTIVE' AND permission.deleted = 0
|
||||
WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT'
|
||||
AND owner_role.role_code = 'MERCHANT_OWNER'
|
||||
AND owner_role.status = 'ACTIVE' AND owner_role.deleted = 0
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
-- 一期客户档案字段;与 iam/db/phase1-customer-profile.sql 保持一致。
|
||||
DROP PROCEDURE IF EXISTS regression_add_iam_column;
|
||||
DELIMITER $$
|
||||
@@ -73,6 +124,7 @@ END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL regression_add_iam_column('contact_wechat', 'VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email');
|
||||
CALL regression_add_iam_column('legal_representative', 'VARCHAR(64) NULL COMMENT ''法定代表人'' AFTER legal_name');
|
||||
CALL regression_add_iam_column('sales_owner_name', 'VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat');
|
||||
CALL regression_add_iam_column('payment_term_days', 'SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name');
|
||||
CALL regression_add_iam_column('enterprise_type', 'VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days');
|
||||
@@ -81,8 +133,40 @@ CALL regression_add_iam_column('registered_capital', 'DECIMAL(18,2) NULL COMMENT
|
||||
CALL regression_add_iam_column('registered_capital_currency', 'CHAR(3) NULL COMMENT ''注册资本币种 ISO 4217'' AFTER registered_capital');
|
||||
CALL regression_add_iam_column('registered_address', 'VARCHAR(500) NULL COMMENT ''注册地址文本'' AFTER registered_capital_currency');
|
||||
CALL regression_add_iam_column('internal_remark', 'VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address');
|
||||
CALL regression_add_iam_column('approved_at', 'DATETIME(3) NULL COMMENT ''审核通过时间'' AFTER internal_remark');
|
||||
CALL regression_add_iam_column('approved_by', 'BIGINT NULL COMMENT ''审核人ID'' AFTER approved_at');
|
||||
CALL regression_add_iam_column('reject_reason', 'VARCHAR(500) NULL COMMENT ''驳回原因'' AFTER approved_by');
|
||||
DROP PROCEDURE regression_add_iam_column;
|
||||
|
||||
-- 注册审核和资质替换依赖的辅助字段;存量测试库也必须具备同一契约。
|
||||
DROP PROCEDURE IF EXISTS regression_add_iam_related_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE regression_add_iam_related_column(
|
||||
IN p_table_name VARCHAR(64),
|
||||
IN p_column_name VARCHAR(64),
|
||||
IN p_definition VARCHAR(1000)
|
||||
)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = p_table_name
|
||||
AND column_name = p_column_name
|
||||
) THEN
|
||||
SET @regression_iam_related_sql = CONCAT(
|
||||
'ALTER TABLE `', p_table_name, '` ADD COLUMN `', p_column_name, '` ', p_definition
|
||||
);
|
||||
PREPARE regression_iam_related_stmt FROM @regression_iam_related_sql;
|
||||
EXECUTE regression_iam_related_stmt;
|
||||
DEALLOCATE PREPARE regression_iam_related_stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL regression_add_iam_related_column('iam_tenant_qualification', 'review_comment', 'VARCHAR(500) NULL COMMENT ''审核意见''');
|
||||
CALL regression_add_iam_related_column('iam_membership', 'created_by', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''创建人ID''');
|
||||
CALL regression_add_iam_related_column('iam_membership', 'updated_by', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最后修改人ID''');
|
||||
DROP PROCEDURE regression_add_iam_related_column;
|
||||
|
||||
-- 先写入父菜单,再写入按钮,避免新增环境出现悬空父节点。
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
@@ -122,6 +206,9 @@ VALUES
|
||||
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', NULL, NULL, 20, 'ACTIVE', 1, 0),
|
||||
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', NULL, NULL, 30, 'ACTIVE', 1, 0),
|
||||
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 1, 0),
|
||||
(5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 1, 0),
|
||||
(5100021, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:approve', '审核通过', NULL, NULL, 60, 'ACTIVE', 1, 0),
|
||||
(5100022, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reject', '审核驳回', NULL, NULL, 70, 'ACTIVE', 1, 0),
|
||||
(5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增内部用户', NULL, NULL, 10, 'ACTIVE', 1, 0),
|
||||
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑内部用户', NULL, NULL, 20, 'ACTIVE', 1, 0),
|
||||
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停内部用户', NULL, NULL, 30, 'ACTIVE', 1, 0),
|
||||
@@ -207,6 +294,7 @@ SELECT r.id, p.id, 0
|
||||
AND p.permission_code IN (
|
||||
'platform:customer', 'platform:customer:list', 'platform:customer:create',
|
||||
'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export',
|
||||
'platform:customer:review', 'platform:customer:approve', 'platform:customer:reject',
|
||||
'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit',
|
||||
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
||||
'platform:role:list', 'platform:role:create', 'platform:role:grant',
|
||||
@@ -698,6 +786,7 @@ WHERE deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:customer', 'platform:customer:list', 'platform:customer:create',
|
||||
'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export',
|
||||
'platform:customer:review', 'platform:customer:approve', 'platform:customer:reject',
|
||||
'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit',
|
||||
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
||||
'platform:role:list', 'platform:role:create', 'platform:role:grant',
|
||||
@@ -719,17 +808,82 @@ WHERE deleted = 0
|
||||
SELECT
|
||||
'IAM客户档案字段' AS check_item,
|
||||
COUNT(*) AS actual_count,
|
||||
9 AS expected_count,
|
||||
IF(COUNT(*) = 9, '通过', '请检查客户档案字段迁移') AS check_result
|
||||
10 AS expected_count,
|
||||
IF(COUNT(*) = 10, '通过', '请检查客户档案字段迁移') AS check_result
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_iam'
|
||||
AND table_name = 'iam_tenant'
|
||||
AND column_name IN (
|
||||
'contact_wechat', 'sales_owner_name', 'payment_term_days', 'enterprise_type',
|
||||
'legal_representative', 'contact_wechat', 'sales_owner_name', 'payment_term_days', 'enterprise_type',
|
||||
'established_date', 'registered_capital', 'registered_capital_currency',
|
||||
'registered_address', 'internal_remark'
|
||||
);
|
||||
|
||||
SELECT
|
||||
'IAM注册审核字段' AS check_item,
|
||||
COUNT(*) AS actual_count,
|
||||
4 AS expected_count,
|
||||
IF(COUNT(*) = 4, '通过', '请检查租户审核状态和结果字段') AS check_result
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_iam'
|
||||
AND table_name = 'iam_tenant'
|
||||
AND column_name IN ('status', 'approved_at', 'approved_by', 'reject_reason');
|
||||
|
||||
SELECT
|
||||
'IAM资质与成员审计字段' AS check_item,
|
||||
(
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_iam'
|
||||
AND table_name = 'iam_tenant_qualification'
|
||||
AND column_name = 'review_comment'
|
||||
) + (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_iam'
|
||||
AND table_name = 'iam_membership'
|
||||
AND column_name IN ('created_by', 'updated_by')
|
||||
) AS actual_count,
|
||||
3 AS expected_count,
|
||||
IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_iam'
|
||||
AND table_name = 'iam_tenant_qualification'
|
||||
AND column_name = 'review_comment'
|
||||
) + (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_iam'
|
||||
AND table_name = 'iam_membership'
|
||||
AND column_name IN ('created_by', 'updated_by')
|
||||
) = 3,
|
||||
'通过', '请检查资质审核备注和成员审计字段'
|
||||
) AS check_result;
|
||||
|
||||
SELECT
|
||||
'IAM全局企业管理员模板' AS check_item,
|
||||
COUNT(DISTINCT owner_role.id) AS role_count,
|
||||
COUNT(DISTINCT role_permission.permission_id) AS actual_permission_count,
|
||||
(SELECT COUNT(*) FROM baiyue_iam.iam_permission permission
|
||||
WHERE permission.tenant_id = 0 AND permission.app_code = 'MERCHANT'
|
||||
AND permission.permission_type IN ('MENU', 'BUTTON')
|
||||
AND permission.status = 'ACTIVE' AND permission.deleted = 0) AS expected_permission_count,
|
||||
IF(
|
||||
COUNT(DISTINCT owner_role.id) = 1
|
||||
AND COUNT(DISTINCT role_permission.permission_id) = (
|
||||
SELECT COUNT(*) FROM baiyue_iam.iam_permission permission
|
||||
WHERE permission.tenant_id = 0 AND permission.app_code = 'MERCHANT'
|
||||
AND permission.permission_type IN ('MENU', 'BUTTON')
|
||||
AND permission.status = 'ACTIVE' AND permission.deleted = 0
|
||||
)
|
||||
AND COUNT(DISTINCT role_permission.permission_id) > 0,
|
||||
'通过', '请检查全局MERCHANT_OWNER角色及授权'
|
||||
) AS check_result
|
||||
FROM baiyue_iam.iam_role owner_role
|
||||
LEFT JOIN baiyue_iam.iam_role_permission role_permission
|
||||
ON role_permission.role_id = owner_role.id AND role_permission.tenant_id = 0
|
||||
WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT'
|
||||
AND owner_role.role_code = 'MERCHANT_OWNER'
|
||||
AND owner_role.role_type = 'SYSTEM' AND owner_role.status = 'ACTIVE' AND owner_role.deleted = 0;
|
||||
|
||||
SELECT
|
||||
table_name,
|
||||
COUNT(*) AS column_count
|
||||
|
||||
+46
-2
@@ -16,7 +16,10 @@
|
||||
1. IAM 基线表必须已存在(`iam.sql`)。
|
||||
2. 权限父菜单和既有客户列表/审核权限基线(`phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`)。
|
||||
3. `phase1-platform-master-data-and-permission-management.sql` 中的 `platform:customer:view`、既有角色继承及管理员授权。
|
||||
4. `phase1-customer-profile.sql` 中的九个 `iam_tenant` 客户档案字段。
|
||||
4. `phase1-customer-profile.sql` 中的十个 `iam_tenant` 客户档案字段。
|
||||
5. `phase1-onboarding.sql` 中的 `PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段。
|
||||
|
||||
统一入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。
|
||||
|
||||
测试环境只执行统一入口;模块脚本用于新环境基线构建或单模块升级,不应在统一入口执行后再次串行执行。
|
||||
|
||||
@@ -24,4 +27,45 @@
|
||||
|
||||
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
|
||||
|
||||
执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案九字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。
|
||||
执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案十字段、IAM 注册审核字段、资质与成员审计字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。
|
||||
|
||||
## 公共导出中心开发阶段重建
|
||||
|
||||
统一迁移脚本中的 `file_export_task` 是一期旧基线,不能自动升级已经存在的表。公共导出中心尚未进入生产、测试环境确认没有需要保留的导出任务时,按下列顺序重建;这一步是公共导出中心上线的额外步骤,不得用于已有真实导出数据的生产环境。
|
||||
|
||||
先只读确认任务数为 0:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) AS export_task_count FROM baiyue_file.file_export_task;
|
||||
```
|
||||
|
||||
只有结果为 0 才允许执行:
|
||||
|
||||
```sql
|
||||
USE baiyue_file;
|
||||
DROP TABLE IF EXISTS file_export_outbox;
|
||||
DROP TABLE IF EXISTS file_export_task_guard;
|
||||
DROP TABLE IF EXISTS file_export_task;
|
||||
SOURCE file/src/main/resources/db/file.sql;
|
||||
```
|
||||
|
||||
`SOURCE` 的相对路径以 MySQL 客户端启动目录为准;图形客户端可直接完整执行 `file/src/main/resources/db/file.sql`。完成后必须核验:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) = 3 AS export_tables_ready
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_file'
|
||||
AND table_name IN ('file_export_task', 'file_export_outbox', 'file_export_task_guard');
|
||||
|
||||
SELECT COUNT(*) = 1 AS creation_guard_ready
|
||||
FROM baiyue_file.file_export_task_guard
|
||||
WHERE guard_key = 'GLOBAL_CREATE';
|
||||
|
||||
SELECT COUNT(*) = 8 AS scheduler_columns_ready
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_file' AND table_name = 'file_export_task'
|
||||
AND column_name IN ('dispatch_version', 'attempt_count', 'max_attempts', 'next_retry_at',
|
||||
'lease_owner', 'lease_until', 'last_heartbeat_at', 'execution_deadline_at');
|
||||
```
|
||||
|
||||
任一结果不是 1,禁止启动 File Service。后续一旦表内存在生产数据,必须改用可重复执行的增量迁移,不能继续 DROP 重建。
|
||||
|
||||
+101
-111
@@ -1,161 +1,151 @@
|
||||
# 公共导出与下载中心接入说明
|
||||
|
||||
## 1. 目标与边界
|
||||
本文描述当前已经实现的 RocketMQ + 事务 Outbox 版本。完整状态机、故障模型和容量门槛见[公共导出中心 RocketMQ 架构设计](./公共导出中心RocketMQ架构设计.md)。
|
||||
|
||||
`file-service` 统一承载异步导出任务、XLSX 文件生成结果、过期清理和下载鉴权。业务服务仍是业务数据唯一事实来源,通过内部、只读、分批接口向 File 提供导出数据。
|
||||
## 1. 边界
|
||||
|
||||
- 浏览器不得拉取全部分页后自行生成 Excel。
|
||||
- File 不跨库查询 IAM、Order、Warehouse 等业务数据库。
|
||||
- 任务不保存用户 Bearer Token;服务间调用使用 `X-Internal-Service-Token`。
|
||||
- 当前首个导出类型为 `CUSTOMER_LIST`,数据由 IAM 提供。
|
||||
- 当前存储实现沿用 `FILE_STORAGE_ROOT` 下的共享目录;生产多实例必须挂载同一持久化目录,后续可替换为对象存储。
|
||||
- `file-service` 统一承载任务、可靠调度、通用 XLSX、存储、过期清理、下载鉴权和指标。
|
||||
- File 不跨库读取 IAM、Order、Warehouse 等业务数据;业务服务只实现标准只读 Provider。
|
||||
- MQ 只传任务标识,不传筛选条件、业务行、Token 或文件。
|
||||
- 一期存储是 `FILE_STORAGE_ROOT` 目录且按单节点部署;多实例前必须使用跨主机共享卷或对象存储。
|
||||
- 当前首个类型为 `CUSTOMER_LIST`,由 IAM 提供数据。
|
||||
|
||||
## 2. 调用流程
|
||||
## 2. 执行链路
|
||||
|
||||
```text
|
||||
Admin/Client
|
||||
Admin
|
||||
-> POST /api/platform/v1/files/export-tasks
|
||||
-> file_export_task(PENDING)
|
||||
-> File worker 原子领取(RUNNING)
|
||||
-> 业务服务 internal export API(最多 500 条/批)
|
||||
-> SXSSFWorkbook 流式生成 XLSX
|
||||
-> file_export_task(SUCCEEDED)
|
||||
-> file_export_task_guard 行锁下复核重复提交及活动上限
|
||||
-> 同一事务写 file_export_task + file_export_outbox
|
||||
-> Publisher 投递 RocketMQ(失败无限退避)
|
||||
-> Consumer 按 dispatch_version + 数据库租约领取(并发 4)
|
||||
-> POST /internal/exports/v1/{TYPE}/batches
|
||||
-> 通用 SXSSFWorkbook 流式写盘
|
||||
-> 条件提交 SUCCEEDED
|
||||
-> GET /api/platform/v1/files/export-tasks/{id}/download
|
||||
```
|
||||
|
||||
任务状态只允许:
|
||||
|
||||
```text
|
||||
PENDING -> RUNNING -> SUCCEEDED
|
||||
-> FAILED -> PENDING(手工重试)
|
||||
SUCCEEDED -> EXPIRED
|
||||
PENDING -> RUNNING -> SUCCEEDED -> EXPIRED
|
||||
-> PENDING(自动重试,dispatch_version + 1)
|
||||
-> FAILED -> PENDING(人工重试,dispatch_version + 1)
|
||||
```
|
||||
|
||||
服务重启后数据库中的 `PENDING` 会继续处理;超过配置阈值的 `RUNNING` 会重新排队。多个 File 实例通过 `PENDING` 条件更新原子领取,只有一个实例能成功。
|
||||
消息补投保持同一版本;只有执行失败、执行租约恢复和人工重试递增版本。独立 watchdog 覆盖 Provider 拉取和最终写盘,最终成功 SQL 同时校验任务状态、版本、租约所有者与数据库 deadline。
|
||||
|
||||
## 3. 平台公开接口
|
||||
Reconciler 负责补写缺失 Outbox、同版本重发长期 `SENT` 命令、恢复超时执行租约、释放超时 Outbox 租约及过期文件清理。旧 `ExportTaskWorker` 已删除,避免绕过版本和退避窗口。
|
||||
|
||||
所有接口都经过 Gateway 的现有 `/api/platform/v1/files/**` 路由。
|
||||
## 3. 浏览器 API
|
||||
|
||||
### 3.1 创建导出任务
|
||||
- `POST /api/platform/v1/files/export-tasks`
|
||||
- `GET /api/platform/v1/files/export-tasks`
|
||||
- `GET /api/platform/v1/files/export-tasks/{id}`
|
||||
- `GET /api/platform/v1/files/export-tasks/{id}/download`
|
||||
- `POST /api/platform/v1/files/export-tasks/{id}/retry`
|
||||
|
||||
`POST /api/platform/v1/files/export-tasks`
|
||||
创建示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"exportType": "CUSTOMER_LIST",
|
||||
"format": "XLSX",
|
||||
"taskName": "客户列表",
|
||||
"parameters": {
|
||||
"keyword": "柏悦",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
"parameters": { "keyword": "柏悦", "status": "ACTIVE" }
|
||||
}
|
||||
```
|
||||
|
||||
创建接口按 `exportType` 校验来源权限。客户导出要求 `platform:customer:export`。筛选参数在创建时形成快照,后续页面修改筛选条件不会改变已提交任务。
|
||||
创建按注册表校验访问域、启用状态和来源权限。客户导出要求 `platform:customer:export`;列表和下载要求 `platform:download:list`。默认只能访问本人任务,`platform:download:all` 可访问同一租户与应用域的全部任务。人工重试只允许 `FAILED`,并重新校验类型启用状态和来源权限。
|
||||
|
||||
### 3.2 下载中心列表
|
||||
限制:单用户最多 3 个活动任务、单类型最多 5 个活动任务;同一用户 60 秒内相同类型和规范化参数返回已有活动任务。
|
||||
|
||||
`GET /api/platform/v1/files/export-tasks?page=1&size=20&exportType=CUSTOMER_LIST&status=SUCCEEDED&requestedFrom=2026-08-01&requestedTo=2026-08-03`
|
||||
|
||||
需要 `platform:download:list`。默认只返回当前登录用户创建的任务;拥有 `platform:download:all` 时可查看同一租户和应用域内所有用户任务。
|
||||
|
||||
### 3.3 任务详情、下载和重试
|
||||
|
||||
- `GET /api/platform/v1/files/export-tasks/{id}`
|
||||
- `GET /api/platform/v1/files/export-tasks/{id}/download`
|
||||
- `POST /api/platform/v1/files/export-tasks/{id}/retry`
|
||||
|
||||
下载仅允许 `SUCCEEDED` 且未过期的任务。重试仅允许 `FAILED`,并重新校验该导出类型的来源权限。服务端始终校验任务租户、应用域和申请人归属,前端按钮隐藏不能替代鉴权。
|
||||
|
||||
## 4. IAM 客户数据提供契约
|
||||
|
||||
File 调用:
|
||||
|
||||
`GET /internal/iam/exports/customers?cursor={lastId}&limit=500&keyword={keyword}&status={status}`
|
||||
|
||||
请求头:
|
||||
## 4. Provider 契约
|
||||
|
||||
```http
|
||||
POST /internal/exports/v1/{exportType}/batches
|
||||
X-Internal-Service-Token: ${INTERNAL_SERVICE_TOKEN}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
响应:
|
||||
请求使用 `common-core` 的 `ExportBatchRequest`,必须包含 `taskId`、`tenantId`、`appCode`、`requestedBy`、`dispatchVersion`、`parameters`、`cursor`、`snapshotToken` 和 `limit`。Provider 必须按身份字段约束查询,不得信任 parameters 内的租户字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "200",
|
||||
"data": {
|
||||
"records": [],
|
||||
"nextCursor": 190000000000000001,
|
||||
"hasMore": false
|
||||
}
|
||||
}
|
||||
响应使用 `ExportBatchResponse`:首批返回 `schemaVersion`、`schemaHash`、`snapshotToken`、`columns`、`rows`、游标和估算总行数;后续批次必须保持 `schemaHash`,可省略 `columns`。File 在 Feign 解码流读取阶段限制单批 4MB,再校验游标推进、列/行/文件上限,并支持 `STRING`、`INTEGER`、`DECIMAL`、`BOOLEAN`、`DATE`、`DATETIME` 六类 Excel 单元格。
|
||||
|
||||
IAM `CUSTOMER_LIST` Provider 强制 `tenantId=0`、`appCode=PLATFORM`,采用 HMAC 签名的最大商户 ID 快照。因此不会混入开始后新增客户,但不冻结期间的修改与删除。
|
||||
|
||||
新增类型只需:业务服务实现标准 Provider、在 `baiyue.file.export.definitions` 注册受控服务/路径/权限/访问域/容量参数、补权限和测试。不得修改公共 Controller、Consumer 或 Writer。
|
||||
|
||||
## 5. MQ 与 DLQ
|
||||
|
||||
- Topic:`${MQ_TOPIC_PREFIX}baiyue-file-export-command-v1`,4 个队列。
|
||||
- Consumer:`${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1`,同步并发 4。
|
||||
- Producer:`${MQ_TOPIC_PREFIX}baiyue-file-export-publisher-v1`。
|
||||
- `eventId`、`taskId`、`dispatchVersion`、`schemaVersion` 同时写入 User Properties。
|
||||
- 业务失败从不进入 DLQ;只有无法反序列化或不支持版本的消息不确认消费。
|
||||
- DLQ Handler 不解析损坏 body,只从四个属性定位,并在同一事务内把 Outbox 置 `QUARANTINED`、当前 PENDING 任务置 `FAILED`。缺少定位属性只记录 CRITICAL 和 messageId,不误伤任务。
|
||||
- DLQ Handler 的数据库处置失败持续重试,不允许因有限次数耗尽进入无人消费的二级 DLQ。
|
||||
|
||||
## 6. 配置与部署
|
||||
|
||||
关键环境变量:
|
||||
|
||||
```text
|
||||
ROCKETMQ_NAME_SERVER
|
||||
ROCKETMQ_BROKER_IP
|
||||
MQ_TOPIC_PREFIX
|
||||
FILE_EXPORT_CONSUMER_CONCURRENCY
|
||||
FILE_EXPORT_RECONCILER_ENABLED
|
||||
FILE_EXPORT_PUBLISHER_ENABLED
|
||||
FILE_EXPORT_CONSUMER_ENABLED
|
||||
FILE_EXPORT_RETENTION_DAYS
|
||||
FILE_STORAGE_ROOT
|
||||
```
|
||||
|
||||
IAM 按租户 ID 升序游标读取,只查询 `tenant_type=MERCHANT`,并复用客户列表的 `keyword/status` 语义。导出 DTO 与平台列表 DTO 分离,因此不会复用列表中的电话和邮箱脱敏结果。
|
||||
`MQ_TOPIC_PREFIX` 是部署必填项但允许空值:测试环境显式填 `test-`,生产环境显式填空字符串。不能使用 `:-test-` 作为 Compose 默认值,否则生产空值会被重新替换成测试前缀。
|
||||
|
||||
## 5. 新增其他导出类型
|
||||
当前 RocketMQ Compose 不映射 Broker 宿主机端口,File 与 Broker 必须同在 `infra-network`,因此 `ROCKETMQ_BROKER_IP` 填 `rocketmq-broker`。Topic 初始化会等待 `BaiyueCluster` 真正注册,任一幂等初始化命令失败后自动重试。
|
||||
|
||||
以订单导出为例:
|
||||
File 容器初始 JVM 为 `-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError`。Provider 单批连接/读取超时分别为 3 秒/15 秒;执行租约 300 秒、watchdog 60 秒、deadline 30 分钟、消费超时 90 分钟、单文件 50MB。
|
||||
|
||||
1. 数据所属服务提供带内部令牌校验的只读游标接口,不允许 File 直接访问其数据库。
|
||||
2. 在 File 增加一个 `ExportTaskHandler` Bean:
|
||||
- `exportType()` 返回稳定编码,例如 `FIRST_LEG_ORDER_LIST`;
|
||||
- `requiredPermission()` 返回创建该任务所需的业务权限;
|
||||
- `export(...)` 分批调用来源服务、流式写入文件并报告进度。
|
||||
3. 增加对应 Feign Client 和导出专用 DTO,不复用面向页面的脱敏或分页 DTO。
|
||||
4. 添加稳定权限资源、管理员授权和前端任务类型显示。
|
||||
5. 增加处理器 XLSX 内容测试、来源接口筛选/游标测试和前端提交测试。
|
||||
开发阶段确认无保留任务后,重建并执行 `file/src/main/resources/db/file.sql`。RocketMQ 使用 `deploy/docker-compose.rocketmq.yml`;脚本会先校验并展开 `ROCKETMQ_BROKER_IP`,再显式创建 4 队列命令 Topic、Worker 消费组、DLQ Handler 消费组以及可读写的 Worker DLQ。上线顺序:Broker -> SQL -> Nacos 注册表 -> IAM Provider -> File 三开关全关 -> Reconciler -> Publisher -> Consumer -> Admin。
|
||||
|
||||
不要在公共 Controller 中增加按类型展开的业务查询分支;类型扩展通过 Spring 注入的 `ExportTaskHandler` 注册表完成。
|
||||
生产启用并发 4 前必须完成 4 个 20 万行任务并发压测;若 CPU、堆、磁盘、Provider 或 IAM 普通接口延迟不达设计门槛,回退并发 2。
|
||||
|
||||
## 6. 存储、保留与配置
|
||||
## 7. 观测与验收
|
||||
|
||||
```yaml
|
||||
baiyue:
|
||||
file:
|
||||
storage-root: ${FILE_STORAGE_ROOT}
|
||||
export:
|
||||
retention-days: ${FILE_EXPORT_RETENTION_DAYS:7}
|
||||
worker-delay-ms: ${FILE_EXPORT_WORKER_DELAY_MS:2000}
|
||||
stale-running-minutes: ${FILE_EXPORT_STALE_RUNNING_MINUTES:30}
|
||||
Prometheus 指标包括任务创建/完成/耗时、队列年龄、Outbox 数量/年龄/最大尝试次数、Reconciler 动作与 `export_dlq_messages_total`;日志携带任务与版本标识但不打印完整数据或令牌。
|
||||
|
||||
最低验收:任务与 Outbox 同事务;Broker 停机恢复后追平;重复消息只生成一个有效文件;watchdog 覆盖最终写盘;deadline 竞态不能提交成功;同版本补投不递增版本;毒消息只隔离一次;IAM 快照不混入新增客户;生成的 XLSX 可真实打开且日期、金额为可计算类型;普通用户不能访问他人任务。
|
||||
|
||||
### 7.1 测试环境自动验收入口
|
||||
|
||||
`scripts/export-center-test-env-acceptance.ps1` 通过 Gateway 创建真实 `CUSTOMER_LIST` 任务,轮询终态,下载文件并校验 XLSX 的 ZIP 签名。Token 只通过参数或环境变量注入,脚本不会登录、部署、停止 Broker 或改数据库。
|
||||
|
||||
单任务冒烟:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_ACCESS_TOKEN = '<测试环境平台用户Token>'
|
||||
.\scripts\export-center-test-env-acceptance.ps1 `
|
||||
-GatewayBaseUrl 'http://8.163.30.128:9000'
|
||||
```
|
||||
|
||||
导出文件位于 `${FILE_STORAGE_ROOT}/exports/{taskId}/{uuid}.xlsx`。`object_key` 必须匹配服务端固定格式,任意绝对路径、`..` 或非 XLSX 后缀都会被拒绝。任务过期后先删除文件,再标记 `EXPIRED`。
|
||||
并发 4 容量验收必须使用 4 个不同申请用户,避免 60 秒重复提交合并,并要求每个账号都具备 `platform:customer:export` 与 `platform:download:list`:
|
||||
|
||||
## 7. SQL 与部署顺序
|
||||
```powershell
|
||||
$tokens = @($env:EXPORT_TOKEN_1, $env:EXPORT_TOKEN_2, $env:EXPORT_TOKEN_3, $env:EXPORT_TOKEN_4)
|
||||
.\scripts\export-center-test-env-acceptance.ps1 `
|
||||
-GatewayBaseUrl 'http://8.163.30.128:9000' `
|
||||
-AccessTokens $tokens -TaskCount 4 -MinimumRows 200000 -TimeoutSeconds 3600
|
||||
```
|
||||
|
||||
新环境:
|
||||
故障注入分两段执行:故障期间使用 `-CreateOnly` 创建并保存任务 ID;恢复 Broker、File 或 IAM 后,用相同申请人的 Token 和 `-TaskIds` 继续轮询与下载。这样停止/恢复基础设施仍由有权限的运维操作,脚本只验证任务是否最终追平:
|
||||
|
||||
1. 执行 `iam/src/main/resources/db/phase1-admin-rbac.sql` 及后续 IAM 权限脚本。
|
||||
2. 执行 `file/src/main/resources/db/file.sql` 创建 `file_export_task`。
|
||||
3. 部署 IAM,再部署 File,最后部署 Admin 静态资源。
|
||||
```powershell
|
||||
.\scripts\export-center-test-env-acceptance.ps1 `
|
||||
-GatewayBaseUrl 'http://8.163.30.128:9000' -AccessTokens $tokens -TaskCount 4 -CreateOnly
|
||||
|
||||
现有测试环境可执行 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql`。脚本幂等创建任务表并补齐:
|
||||
.\scripts\export-center-test-env-acceptance.ps1 `
|
||||
-GatewayBaseUrl 'http://8.163.30.128:9000' -AccessTokens $tokens `
|
||||
-TaskIds @('<taskId1>', '<taskId2>', '<taskId3>', '<taskId4>') -MinimumRows 200000
|
||||
```
|
||||
|
||||
- `platform:customer:export`
|
||||
- `platform:download:list`
|
||||
- `platform:download:all`
|
||||
|
||||
File 启动前必须确认 `FILE_STORAGE_ROOT` 是持久化且可写的共享目录。
|
||||
|
||||
## 8. 错误与追踪
|
||||
|
||||
- 创建、列表和重试使用统一 `ApiResponse`,失败记录业务码、`requestId`、`traceId`。
|
||||
- 下载接口成功返回文件流;失败仍由统一异常处理返回 JSON 错误。
|
||||
- 来源服务不可用或返回非成功业务码时任务进入 `FAILED`,下载中心展示可理解的失败原因。
|
||||
- File 日志使用任务 ID 和导出类型关联故障;不得打印内部令牌、Bearer Token 或完整导出数据。
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
- 创建任务后浏览器没有批量调用业务列表,也没有生成 HTML `.xls`。
|
||||
- 当前筛选条件原样进入 `parameters_json`。
|
||||
- 两个 worker 同时领取时只有一个能将任务从 `PENDING` 更新为 `RUNNING`。
|
||||
- XLSX 可被标准办公软件打开,表头和数据列匹配。
|
||||
- 普通用户看不到、下载不了其他用户任务;`platform:download:all` 用户可以。
|
||||
- `FAILED` 可以重试,其他状态不能重试。
|
||||
- 过期任务不能下载,文件被清理。
|
||||
- 服务重启后 `PENDING` 继续执行,超时 `RUNNING` 能重新排队。
|
||||
- Admin 下载中心覆盖加载、空数据、失败、处理中、完成、失败、过期和窄屏操作状态。
|
||||
脚本通过只证明 API 创建、数据库状态机、MQ 触发、Provider、XLSX 写入和下载主链路。CPU/堆/磁盘、Provider P95/P99、IAM 普通接口退化比例仍须同时从 Prometheus/Grafana 取证;DLQ 毒消息必须由 `mqadmin` 按 §19.2 投递,不能由普通验收账号伪造。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@
|
||||
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
|
||||
| 服务状态 | `controller/FileHealthController.java` |
|
||||
| 当前本地磁盘实现 | `service/LocalQualificationStorage.java` |
|
||||
| 公共导出 API | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java` |
|
||||
| 可靠调度 | `service/ExportStateService.java`、`mq/ExportOutboxPublisher.java`、`mq/ExportCommandConsumer.java`、`service/ExportReconciler.java` |
|
||||
| 通用 Provider/XLSX | `client/ExportProviderClient.java`、`service/GenericXlsxExportRunner.java`、公共契约 `../common-core/.../export/ExportContracts.java` |
|
||||
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
|
||||
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
|
||||
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
|
||||
| 目标表结构(尚未接入) | `src/main/resources/db/file.sql` |
|
||||
@@ -26,6 +30,7 @@
|
||||
## 修改与验证
|
||||
|
||||
- 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。
|
||||
- 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。
|
||||
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
|
||||
- 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。
|
||||
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry.Definition;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Component
|
||||
public class ExportProviderClient {
|
||||
private final Map<String, ExportProviderFeignClient> clients;
|
||||
private final MeterRegistry meters;
|
||||
|
||||
public ExportProviderClient(IamExportProviderClient iam, OrderExportProviderClient order,
|
||||
WarehouseExportProviderClient warehouse, FirstlegExportProviderClient firstleg,
|
||||
MeterRegistry meters) {
|
||||
this.clients = Map.of("iam-service", iam, "order-service", order,
|
||||
"warehouse-service", warehouse, "firstleg-service", firstleg);
|
||||
this.meters = meters;
|
||||
}
|
||||
|
||||
public ApiResponse<ExportBatchResponse> batch(String internalToken, String exportType,
|
||||
Definition definition, ExportBatchRequest request) {
|
||||
ExportProviderFeignClient client = clients.get(definition.getProviderService());
|
||||
if (client == null) throw new IllegalStateException("Export provider is not allowlisted: " + definition.getProviderService());
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
ApiResponse<ExportBatchResponse> response = client.batch(internalToken, exportType, request);
|
||||
if (response == null || !response.successful()) failure(definition.getProviderService(), exportType);
|
||||
return response;
|
||||
} catch (RuntimeException exception) {
|
||||
failure(definition.getProviderService(), exportType);
|
||||
throw exception;
|
||||
} finally {
|
||||
meters.timer("export_provider_request_seconds", "service", definition.getProviderService(), "type", exportType)
|
||||
.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void failure(String service, String type) {
|
||||
meters.counter("export_provider_failure_total", "service", service, "type", type).increment();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
interface ExportProviderFeignClient {
|
||||
@PostMapping("/internal/exports/v1/{exportType}/batches")
|
||||
ApiResponse<ExportBatchResponse> batch(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable String exportType,
|
||||
@RequestBody ExportBatchRequest request);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import feign.Response;
|
||||
import feign.codec.Decoder;
|
||||
import org.springframework.beans.factory.ObjectFactory;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
|
||||
import org.springframework.cloud.openfeign.support.SpringDecoder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class ExportProviderFeignConfiguration {
|
||||
static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@Bean
|
||||
Decoder exportProviderDecoder(ObjectFactory<HttpMessageConverters> converters) {
|
||||
Decoder delegate = new SpringDecoder(converters);
|
||||
return (response, type) -> delegate.decode(withLimitedBody(response), type);
|
||||
}
|
||||
|
||||
private Response withLimitedBody(Response response) throws IOException {
|
||||
if (response.body() == null) return response;
|
||||
byte[] bytes;
|
||||
try (InputStream input = response.body().asInputStream()) {
|
||||
bytes = input.readNBytes(MAX_RESPONSE_BYTES + 1);
|
||||
}
|
||||
if (bytes.length > MAX_RESPONSE_BYTES) throw new ExportProviderResponseTooLargeException(MAX_RESPONSE_BYTES);
|
||||
return response.toBuilder().body(bytes).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
public class ExportProviderResponseTooLargeException extends RuntimeException {
|
||||
public ExportProviderResponseTooLargeException(long limit) {
|
||||
super("Provider 单批响应超过 " + limit + " 字节");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
@FeignClient(name="firstleg-service", contextId="firstlegExportProviderClient", url="${baiyue.clients.firstleg.url:}", configuration=ExportProviderFeignConfiguration.class)
|
||||
interface FirstlegExportProviderClient extends ExportProviderFeignClient { }
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "iam-service", contextId = "iamExportClient",
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamExportClient {
|
||||
@GetMapping("/exports/customers")
|
||||
ApiResponse<CustomerExportBatch> customers(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestParam(required = false) Long cursor,
|
||||
@RequestParam(defaultValue = "500") int limit,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status);
|
||||
|
||||
record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
|
||||
|
||||
record CustomerExportRow(
|
||||
String id, String tenantCode, String tenantName, String legalName,
|
||||
String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
|
||||
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
|
||||
String contactName, String contactPhone, String contactEmail, String contactWechat,
|
||||
String salesOwnerName, String servicePackage, Integer paymentTermDays,
|
||||
String status, LocalDateTime createdAt, LocalDateTime approvedAt) { }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
@FeignClient(name="iam-service", contextId="iamExportProviderClient", url="${baiyue.clients.iam.url:}", configuration=ExportProviderFeignConfiguration.class)
|
||||
interface IamExportProviderClient extends ExportProviderFeignClient { }
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
@FeignClient(name="order-service", contextId="orderExportProviderClient", url="${baiyue.clients.order.url:}", configuration=ExportProviderFeignConfiguration.class)
|
||||
interface OrderExportProviderClient extends ExportProviderFeignClient { }
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
@FeignClient(name="warehouse-service", contextId="warehouseExportProviderClient", url="${baiyue.clients.warehouse.url:}", configuration=ExportProviderFeignConfiguration.class)
|
||||
interface WarehouseExportProviderClient extends ExportProviderFeignClient { }
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "baiyue.file.export")
|
||||
public class ExportDefinitionRegistry {
|
||||
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service");
|
||||
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
|
||||
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
|
||||
private static final Pattern PERMISSION = Pattern.compile("[a-z]+:[a-z-]+:[a-z-]+");
|
||||
private Map<String, Definition> definitions = Map.of();
|
||||
|
||||
@PostConstruct
|
||||
public void validate() {
|
||||
if (definitions.isEmpty()) throw new IllegalStateException("At least one export definition is required");
|
||||
definitions.forEach(this::validateDefinition);
|
||||
}
|
||||
|
||||
public Definition require(String exportType) {
|
||||
Definition definition = definitions.get(exportType);
|
||||
if (definition == null) throw new IllegalArgumentException("Unsupported export type: " + exportType);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private void validateDefinition(String type, Definition d) {
|
||||
if (!TYPE.matcher(type).matches() || d == null) throw new IllegalStateException("Invalid export type: " + type);
|
||||
if (!SERVICES.contains(d.providerService)) throw new IllegalStateException("Export provider is not allowlisted: " + d.providerService);
|
||||
var path = PATH.matcher(d.providerPath == null ? "" : d.providerPath);
|
||||
if (!path.matches() || !type.equals(path.group(1))) throw new IllegalStateException("Invalid provider path for " + type);
|
||||
if (d.requiredPermission == null || !PERMISSION.matcher(d.requiredPermission).matches()) throw new IllegalStateException("Invalid export permission for " + type);
|
||||
if (!Set.of("PLATFORM", "MERCHANT").contains(d.appScope)) throw new IllegalStateException("Invalid app scope for " + type);
|
||||
if (d.maxRows < 1 || d.maxRows > 500_000 || d.batchSize < 50 || d.batchSize > 1_000 || d.maxAttempts < 1 || d.maxAttempts > 5) {
|
||||
throw new IllegalStateException("Invalid export limits for " + type);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Definition> getDefinitions() { return definitions; }
|
||||
public void setDefinitions(Map<String, Definition> definitions) {
|
||||
Map<String, Definition> candidate = definitions == null ? Map.of() : Map.copyOf(definitions);
|
||||
if (candidate.isEmpty()) throw new IllegalStateException("At least one export definition is required");
|
||||
candidate.forEach(this::validateDefinition);
|
||||
this.definitions = candidate;
|
||||
}
|
||||
|
||||
public static class Definition {
|
||||
private String providerService;
|
||||
private String providerPath;
|
||||
private String requiredPermission;
|
||||
private String appScope;
|
||||
private boolean enabled = true;
|
||||
private int maxRows = 200_000;
|
||||
private int maxAttempts = 3;
|
||||
private int batchSize = 500;
|
||||
public String getProviderService() { return providerService; }
|
||||
public void setProviderService(String value) { providerService = value; }
|
||||
public String getProviderPath() { return providerPath; }
|
||||
public void setProviderPath(String value) { providerPath = value; }
|
||||
public String getRequiredPermission() { return requiredPermission; }
|
||||
public void setRequiredPermission(String value) { requiredPermission = value; }
|
||||
public String getAppScope() { return appScope; }
|
||||
public void setAppScope(String value) { appScope = value; }
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean value) { enabled = value; }
|
||||
public int getMaxRows() { return maxRows; }
|
||||
public void setMaxRows(int value) { maxRows = value; }
|
||||
public int getMaxAttempts() { return maxAttempts; }
|
||||
public void setMaxAttempts(int value) { maxAttempts = value; }
|
||||
public int getBatchSize() { return batchSize; }
|
||||
public void setBatchSize(int value) { batchSize = value; }
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public class PlatformQualificationFileController {
|
||||
@RequestParam(required = false) LocalDate validTo,
|
||||
@RequestParam(defaultValue = "false") boolean replace) {
|
||||
requirePlatform(user);
|
||||
qualificationType = QualificationTypes.requireSupported(qualificationType);
|
||||
var stored = storage.store(tenantId, file);
|
||||
try {
|
||||
var response = iamClient.submitPlatform(internalToken,
|
||||
|
||||
@@ -33,11 +33,12 @@ public class QualificationFileController {
|
||||
public ApiResponse<UploadResponse> upload(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestParam(defaultValue = "BUSINESS_LICENSE") String qualificationType,
|
||||
@RequestParam String qualificationType,
|
||||
@RequestParam(value = "certificateNo", required = false) String certificateNo,
|
||||
@RequestParam(required = false) LocalDate validTo,
|
||||
@RequestParam(defaultValue = "false") boolean replace) {
|
||||
long tenantId = merchantTenant(user);
|
||||
qualificationType = QualificationTypes.requireSupported(qualificationType);
|
||||
var stored = storage.store(tenantId, file);
|
||||
try {
|
||||
var response = iamClient.submit(internalToken,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
final class QualificationTypes {
|
||||
private static final Set<String> SUPPORTED = Set.of(
|
||||
"BUSINESS_LICENSE",
|
||||
"LEGAL_PERSON_ID",
|
||||
"FREIGHT_FORWARDER_CERTIFICATE",
|
||||
"BANK_ACCOUNT_LICENSE");
|
||||
|
||||
private QualificationTypes() { }
|
||||
|
||||
static String requireSupported(String qualificationType) {
|
||||
if (qualificationType == null || !SUPPORTED.contains(qualificationType)) {
|
||||
throw new BizException("Unsupported qualification type / 不支持的资质类型");
|
||||
}
|
||||
return qualificationType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.file.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("file_export_outbox")
|
||||
public class FileExportOutbox {
|
||||
@TableId private Long id;
|
||||
private String eventId;
|
||||
private Long taskId;
|
||||
private Integer dispatchVersion;
|
||||
private String topic;
|
||||
private String tag;
|
||||
private String messageKey;
|
||||
private String payloadJson;
|
||||
private String status;
|
||||
private Integer attemptCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private LocalDateTime sentAt;
|
||||
private String lastError;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -28,6 +28,14 @@ public class FileExportTask extends BaseEntity {
|
||||
private String contentType;
|
||||
private Long sizeBytes;
|
||||
private String failureMessage;
|
||||
private Integer dispatchVersion;
|
||||
private Integer attemptCount;
|
||||
private Integer maxAttempts;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private LocalDateTime lastHeartbeatAt;
|
||||
private LocalDateTime executionDeadlineAt;
|
||||
private Long requestedBy;
|
||||
private String requestedUsername;
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.file.mapper;
|
||||
|
||||
import com.baiyue.file.entity.FileExportOutbox;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FileExportOutboxMapper extends BaseMapper<FileExportOutbox> {
|
||||
@Insert("""
|
||||
INSERT INTO file_export_outbox(id,event_id,task_id,dispatch_version,topic,tag,message_key,payload_json,
|
||||
status,attempt_count,next_retry_at,created_at,updated_at)
|
||||
VALUES(#{id},#{eventId},#{taskId},#{dispatchVersion},#{topic},#{tag},#{messageKey},CAST(#{payloadJson} AS JSON),
|
||||
'PENDING',0,(SELECT next_retry_at FROM file_export_task WHERE id=#{taskId}),CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insertCommand(FileExportOutbox row);
|
||||
@Select("SELECT id FROM file_export_outbox WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3) ORDER BY next_retry_at,id LIMIT #{limit}")
|
||||
List<Long> publishableIds(@Param("limit") int limit);
|
||||
|
||||
@Update("UPDATE file_export_outbox SET status='CLAIMED',lease_owner=#{owner},lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND),attempt_count=attempt_count+1 WHERE id=#{id} AND status='PENDING'")
|
||||
int claim(@Param("id") long id, @Param("owner") String owner, @Param("leaseSeconds") int leaseSeconds);
|
||||
|
||||
@Update("UPDATE file_export_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE id=#{id} AND status='CLAIMED' AND lease_owner=#{owner}")
|
||||
int sent(@Param("id") long id, @Param("owner") String owner);
|
||||
|
||||
@Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND),lease_owner=NULL,lease_until=NULL,last_error=#{error} WHERE id=#{id} AND status='CLAIMED' AND lease_owner=#{owner}")
|
||||
int sendFailed(@Param("id") long id, @Param("owner") String owner,
|
||||
@Param("backoffSeconds") int backoffSeconds, @Param("error") String error);
|
||||
|
||||
@Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50")
|
||||
int releaseExpiredClaims();
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_outbox o JOIN file_export_task t
|
||||
ON t.id=o.task_id AND t.dispatch_version=o.dispatch_version
|
||||
SET o.status='PENDING',o.next_retry_at=CURRENT_TIMESTAMP(3),o.lease_owner=NULL,o.lease_until=NULL
|
||||
WHERE o.status='SENT' AND o.sent_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{staleSeconds} SECOND)
|
||||
AND t.status='PENDING' AND t.next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND)
|
||||
LIMIT 20
|
||||
""")
|
||||
int redispatchSent(@Param("staleSeconds") int staleSeconds, @Param("graceSeconds") int graceSeconds);
|
||||
|
||||
@Update("UPDATE file_export_outbox SET status='QUARANTINED',last_error=#{reason},lease_owner=NULL,lease_until=NULL WHERE event_id=#{eventId} AND task_id=#{taskId} AND dispatch_version=#{dispatchVersion} AND status<>'QUARANTINED'")
|
||||
int quarantine(@Param("eventId") String eventId, @Param("taskId") long taskId,
|
||||
@Param("dispatchVersion") int dispatchVersion, @Param("reason") String reason);
|
||||
|
||||
@Delete("DELETE FROM file_export_outbox WHERE status='SENT' AND sent_at<DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{retentionDays} DAY) ORDER BY sent_at LIMIT #{limit}")
|
||||
int deleteRetainedSent(@Param("retentionDays") int retentionDays, @Param("limit") int limit);
|
||||
}
|
||||
@@ -3,6 +3,136 @@ package com.baiyue.file.mapper;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface FileExportTaskMapper extends BaseMapper<FileExportTask> { }
|
||||
public interface FileExportTaskMapper extends BaseMapper<FileExportTask> {
|
||||
@Select("SELECT guard_key FROM file_export_task_guard WHERE guard_key='GLOBAL_CREATE' FOR UPDATE")
|
||||
String lockCreationGuard();
|
||||
|
||||
@Select("SELECT guard_key FROM file_export_task_guard WHERE guard_key='GLOBAL_CREATE'")
|
||||
String findCreationGuard();
|
||||
|
||||
@Update("UPDATE file_export_task SET next_retry_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='PENDING' AND dispatch_version=1")
|
||||
int initializeDispatchTime(@Param("id") long id);
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='RUNNING', attempt_count=attempt_count+1,
|
||||
started_at=CURRENT_TIMESTAMP(3), lease_owner=#{owner},
|
||||
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND),
|
||||
last_heartbeat_at=CURRENT_TIMESTAMP(3),
|
||||
execution_deadline_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{durationMinutes} MINUTE),
|
||||
processed_rows=0,total_rows=NULL,failure_message=NULL
|
||||
WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'
|
||||
AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int claim(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("owner") String owner, @Param("leaseSeconds") int leaseSeconds,
|
||||
@Param("durationMinutes") int durationMinutes);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND),
|
||||
last_heartbeat_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status='RUNNING'
|
||||
AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner}
|
||||
AND execution_deadline_at>CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int heartbeat(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("owner") String owner, @Param("leaseSeconds") int leaseSeconds);
|
||||
|
||||
@Update("UPDATE file_export_task SET processed_rows=#{rows} WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner}")
|
||||
int progress(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("owner") String owner, @Param("rows") long rows);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='SUCCEEDED',file_name=#{fileName},object_key=#{objectKey},
|
||||
content_type=#{contentType},size_bytes=#{sizeBytes},total_rows=#{rows},processed_rows=#{rows},
|
||||
completed_at=CURRENT_TIMESTAMP(3),expires_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{retentionDays} DAY),
|
||||
lease_owner=NULL,lease_until=NULL,failure_message=NULL
|
||||
WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion}
|
||||
AND lease_owner=#{owner} AND execution_deadline_at>CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int succeed(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("owner") String owner, @Param("fileName") String fileName,
|
||||
@Param("objectKey") String objectKey, @Param("contentType") String contentType,
|
||||
@Param("sizeBytes") long sizeBytes, @Param("rows") long rows,
|
||||
@Param("retentionDays") int retentionDays);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='PENDING',dispatch_version=dispatch_version+1,
|
||||
next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND),
|
||||
lease_owner=NULL,lease_until=NULL,last_heartbeat_at=NULL,execution_deadline_at=NULL,
|
||||
failure_message=#{message} WHERE id=#{id} AND status='RUNNING'
|
||||
AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner} AND attempt_count<max_attempts
|
||||
""")
|
||||
int scheduleRetry(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("owner") String owner, @Param("backoffSeconds") int backoffSeconds,
|
||||
@Param("message") String message);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,last_heartbeat_at=NULL,failure_message=#{message}
|
||||
WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion} AND lease_owner=#{owner}
|
||||
""")
|
||||
int fail(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("owner") String owner, @Param("message") String message);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='PENDING',dispatch_version=dispatch_version+1,
|
||||
next_retry_at=CURRENT_TIMESTAMP(3),processed_rows=0,total_rows=NULL,file_name=NULL,object_key=NULL,
|
||||
content_type=NULL,size_bytes=NULL,failure_message=NULL,started_at=NULL,completed_at=NULL,expires_at=NULL,
|
||||
lease_owner=NULL,lease_until=NULL,last_heartbeat_at=NULL,execution_deadline_at=NULL
|
||||
WHERE id=#{id} AND status='FAILED'
|
||||
""")
|
||||
int manualRetry(@Param("id") long id);
|
||||
|
||||
@Update("UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),failure_message=#{reason},lease_owner=NULL,lease_until=NULL WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'")
|
||||
int failQuarantined(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||
@Param("reason") String reason);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='PENDING',dispatch_version=dispatch_version+1,
|
||||
next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_heartbeat_at=NULL,
|
||||
execution_deadline_at=NULL,failure_message='执行实例失联,任务已重新派发'
|
||||
WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion}
|
||||
AND lease_until<=CURRENT_TIMESTAMP(3) AND attempt_count<max_attempts
|
||||
""")
|
||||
int recoverStale(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,
|
||||
lease_until=NULL,last_heartbeat_at=NULL,failure_message='执行实例失联且已达最大尝试次数'
|
||||
WHERE id=#{id} AND status='RUNNING' AND dispatch_version=#{dispatchVersion}
|
||||
AND lease_until<=CURRENT_TIMESTAMP(3) AND attempt_count>=max_attempts
|
||||
""")
|
||||
int failStale(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion);
|
||||
|
||||
@Select("SELECT * FROM file_export_task WHERE status='SUCCEEDED' AND expires_at<=CURRENT_TIMESTAMP(3) AND object_key IS NOT NULL ORDER BY expires_at LIMIT 50")
|
||||
List<FileExportTask> expiredArtifactsDb();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM file_export_task WHERE tenant_id=#{tenantId} AND app_code=#{appCode} AND requested_by=#{requestedBy} AND status IN ('PENDING','RUNNING')")
|
||||
long activeForUser(@Param("tenantId") long tenantId,@Param("appCode") String appCode,@Param("requestedBy") long requestedBy);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM file_export_task WHERE export_type=#{exportType} AND status IN ('PENDING','RUNNING')")
|
||||
long activeForType(@Param("exportType") String exportType);
|
||||
|
||||
@Select("SELECT * FROM file_export_task WHERE tenant_id=#{tenantId} AND app_code=#{appCode} AND requested_by=#{requestedBy} AND export_type=#{exportType} AND parameters_json=CAST(#{parametersJson} AS JSON) AND status IN ('PENDING','RUNNING') AND requested_at>=DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND) ORDER BY requested_at DESC LIMIT 1")
|
||||
FileExportTask recentDuplicate(@Param("tenantId") long tenantId,@Param("appCode") String appCode,@Param("requestedBy") long requestedBy,@Param("exportType") String exportType,@Param("parametersJson") String parametersJson);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM file_export_task WHERE id=#{id} AND status='SUCCEEDED' AND expires_at>CURRENT_TIMESTAMP(3)")
|
||||
int downloadable(@Param("id") long id);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM file_export_task WHERE status='SUCCEEDED' AND object_key=#{objectKey}")
|
||||
int succeededObjectExists(@Param("objectKey") String objectKey);
|
||||
|
||||
@Select("SELECT * FROM file_export_task WHERE status='RUNNING' AND lease_until<=CURRENT_TIMESTAMP(3) ORDER BY lease_until LIMIT 20")
|
||||
List<FileExportTask> staleRunning();
|
||||
|
||||
@Select("SELECT * FROM file_export_task WHERE status='PENDING' AND next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND) AND NOT EXISTS (SELECT 1 FROM file_export_outbox o WHERE o.task_id=file_export_task.id AND o.dispatch_version=file_export_task.dispatch_version) ORDER BY id LIMIT 50")
|
||||
List<FileExportTask> missingOutbox(@Param("graceSeconds") int graceSeconds);
|
||||
|
||||
@Update("UPDATE file_export_task SET status='EXPIRED' WHERE id=#{id} AND status='SUCCEEDED'")
|
||||
int markExpiredDb(@Param("id") long id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportCommand;
|
||||
import com.baiyue.file.service.ExportTaskExecutor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
|
||||
import org.apache.rocketmq.spring.annotation.MessageModel;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQPushConsumerLifecycleListener;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix="baiyue.file.export.consumer", name="enabled", havingValue="true")
|
||||
@RocketMQMessageListener(topic="${baiyue.file.export.rocketmq.topic}", consumerGroup="${baiyue.file.export.rocketmq.consumer-group}",
|
||||
selectorExpression="EXPORT_TASK", messageModel= MessageModel.CLUSTERING, consumeThreadNumber=4,
|
||||
consumeThreadMax=4, consumeTimeout=90, maxReconsumeTimes=2)
|
||||
public class ExportCommandConsumer implements RocketMQListener<MessageExt>, RocketMQPushConsumerLifecycleListener {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ExportTaskExecutor executor;
|
||||
private final String instance;
|
||||
private final int concurrency;
|
||||
private final long consumeTimeoutMinutes;
|
||||
private final int maxReconsumeTimes;
|
||||
public ExportCommandConsumer(ObjectMapper objectMapper, ExportTaskExecutor executor,
|
||||
@Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") String instance,
|
||||
@Value("${baiyue.file.export.consumer.concurrency:4}") int concurrency,
|
||||
@Value("${baiyue.file.export.consumer.consume-timeout-minutes:90}") long consumeTimeoutMinutes,
|
||||
@Value("${baiyue.file.export.consumer.max-reconsume-times:2}") int maxReconsumeTimes) {
|
||||
this.objectMapper=objectMapper;this.executor=executor;this.instance=instance;
|
||||
if (concurrency < 1 || concurrency > 64) throw new IllegalArgumentException("Export consumer concurrency must be between 1 and 64");
|
||||
this.concurrency=concurrency;this.consumeTimeoutMinutes=consumeTimeoutMinutes;this.maxReconsumeTimes=maxReconsumeTimes;
|
||||
}
|
||||
@Override public void prepareStart(DefaultMQPushConsumer consumer) {
|
||||
consumer.setConsumeThreadMin(concurrency);
|
||||
consumer.setConsumeThreadMax(concurrency);
|
||||
consumer.setConsumeTimeout(consumeTimeoutMinutes);
|
||||
consumer.setMaxReconsumeTimes(maxReconsumeTimes);
|
||||
}
|
||||
@Override public void onMessage(MessageExt message) {
|
||||
final ExportCommand command;
|
||||
try { command = objectMapper.readValue(new String(message.getBody(), StandardCharsets.UTF_8), ExportCommand.class); }
|
||||
catch (Exception exception) { throw new IllegalArgumentException("Malformed export command", exception); }
|
||||
if (command.schemaVersion()!=1 || command.taskId()==null || command.eventId()==null) throw new IllegalArgumentException("Unsupported export command");
|
||||
executor.execute(Long.parseLong(command.taskId()), command.dispatchVersion(), instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.service.ExportStateService;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.apache.rocketmq.spring.annotation.MessageModel;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix="baiyue.file.export.consumer", name="enabled", havingValue="true")
|
||||
@RocketMQMessageListener(topic="${baiyue.file.export.rocketmq.dlq-topic}",
|
||||
consumerGroup="${baiyue.file.export.rocketmq.dlq-consumer-group}", messageModel=MessageModel.CLUSTERING,
|
||||
consumeThreadNumber=1, consumeThreadMax=1, maxReconsumeTimes=Integer.MAX_VALUE)
|
||||
public class ExportDlqHandler implements RocketMQListener<MessageExt> {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ExportDlqHandler.class);
|
||||
private final ExportStateService states;
|
||||
private final MeterRegistry meters;
|
||||
public ExportDlqHandler(ExportStateService states, MeterRegistry meters) { this.states=states; this.meters=meters; }
|
||||
@Override public void onMessage(MessageExt message) {
|
||||
meters.counter("export_dlq_messages").increment();
|
||||
String eventId=message.getUserProperty("eventId"), taskId=message.getUserProperty("taskId");
|
||||
String version=message.getUserProperty("dispatchVersion"), schema=message.getUserProperty("schemaVersion");
|
||||
if (blank(eventId)||blank(taskId)||blank(version)||blank(schema)) {
|
||||
LOGGER.error("CRITICAL: 畸形 DLQ 消息缺少定位属性 messageId={}", message.getMsgId());
|
||||
return;
|
||||
}
|
||||
try { states.quarantine(eventId, Long.parseLong(taskId), Integer.parseInt(version), "消息契约损坏,已自动隔离(schemaVersion="+schema+")"); }
|
||||
catch (NumberFormatException exception) { LOGGER.error("CRITICAL: 畸形 DLQ 消息定位属性非法 messageId={}", message.getMsgId(), exception); }
|
||||
}
|
||||
private boolean blank(String value) { return value==null||value.isBlank(); }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.entity.FileExportOutbox;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.apache.rocketmq.spring.support.RocketMQHeaders;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix="baiyue.file.export.publisher", name="enabled", havingValue="true")
|
||||
public class ExportOutboxPublisher {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ExportOutboxPublisher.class);
|
||||
private static final int[] BACKOFF = {5, 15, 60, 300, 900};
|
||||
private final ExportOutboxRepository outbox;
|
||||
private final RocketMQTemplate rocketMQ;
|
||||
private final String owner;
|
||||
private final int batchSize;
|
||||
private final int leaseSeconds;
|
||||
public ExportOutboxPublisher(ExportOutboxRepository outbox, RocketMQTemplate rocketMQ,
|
||||
@Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") String owner,
|
||||
@Value("${baiyue.file.export.publisher.batch-size:50}") int batchSize,
|
||||
@Value("${baiyue.file.export.publisher.lease-seconds:60}") int leaseSeconds) {
|
||||
this.outbox=outbox;this.rocketMQ=rocketMQ;this.owner=owner;this.batchSize=batchSize;this.leaseSeconds=leaseSeconds;
|
||||
}
|
||||
@Scheduled(fixedDelayString="${baiyue.file.export.publisher.poll-interval-ms:5000}")
|
||||
public void publish() {
|
||||
for (Long id : outbox.publishableIds(batchSize)) {
|
||||
if (!outbox.claim(id, owner, leaseSeconds)) continue;
|
||||
FileExportOutbox row = outbox.find(id);
|
||||
try {
|
||||
var message = MessageBuilder.withPayload(row.getPayloadJson())
|
||||
.setHeader(RocketMQHeaders.KEYS, row.getMessageKey())
|
||||
.setHeader("eventId", row.getEventId())
|
||||
.setHeader("taskId", String.valueOf(row.getTaskId()))
|
||||
.setHeader("dispatchVersion", String.valueOf(row.getDispatchVersion()))
|
||||
.setHeader("schemaVersion", "1").build();
|
||||
rocketMQ.syncSend(row.getTopic() + ":" + row.getTag(), message, 3000);
|
||||
outbox.sent(id, owner);
|
||||
} catch (RuntimeException exception) {
|
||||
int attempt = Math.max(1, row.getAttemptCount());
|
||||
int seconds = BACKOFF[Math.min(attempt - 1, BACKOFF.length - 1)];
|
||||
outbox.sendFailed(id, owner, seconds, safe(exception.getMessage()));
|
||||
LOGGER.error("导出命令发送失败 eventId={} taskId={}", row.getEventId(), row.getTaskId(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
private String safe(String value) { String message=value==null?"RocketMQ send failed":value; return message.length()>1000?message.substring(0,1000):message; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.file.repository;
|
||||
|
||||
import com.baiyue.file.entity.FileExportOutbox;
|
||||
import com.baiyue.file.mapper.FileExportOutboxMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class ExportOutboxRepository {
|
||||
private final FileExportOutboxMapper mapper;
|
||||
public ExportOutboxRepository(FileExportOutboxMapper mapper) { this.mapper = mapper; }
|
||||
public boolean insert(FileExportOutbox row) {
|
||||
try { return mapper.insertCommand(row) == 1; }
|
||||
catch (DuplicateKeyException ignored) { return false; }
|
||||
}
|
||||
public FileExportOutbox find(long id) { return mapper.selectById(id); }
|
||||
public List<Long> publishableIds(int limit) { return mapper.publishableIds(limit); }
|
||||
public boolean claim(long id, String owner, int leaseSeconds) { return mapper.claim(id, owner, leaseSeconds) == 1; }
|
||||
public boolean sent(long id, String owner) { return mapper.sent(id, owner) == 1; }
|
||||
public boolean sendFailed(long id, String owner, int seconds, String error) { return mapper.sendFailed(id, owner, seconds, error) == 1; }
|
||||
public int releaseExpiredClaims() { return mapper.releaseExpiredClaims(); }
|
||||
public int redispatchSent(int staleSeconds, int graceSeconds) { return mapper.redispatchSent(staleSeconds, graceSeconds); }
|
||||
public boolean quarantine(String eventId, long taskId, int version, String reason) { return mapper.quarantine(eventId, taskId, version, reason) == 1; }
|
||||
public int deleteRetainedSent(int retentionDays, int limit) { return mapper.deleteRetainedSent(retentionDays, limit); }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.file.mapper.FileExportTaskMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@@ -21,7 +22,60 @@ public class ExportTaskRepository {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void validateCreationGuard() {
|
||||
if (!"GLOBAL_CREATE".equals(mapper.findCreationGuard())) {
|
||||
throw new IllegalStateException("Missing GLOBAL_CREATE export task guard; apply file.sql before starting File Service");
|
||||
}
|
||||
}
|
||||
|
||||
public void lockCreationGuard() {
|
||||
if (!"GLOBAL_CREATE".equals(mapper.lockCreationGuard())) {
|
||||
throw new IllegalStateException("Unable to acquire export task creation guard");
|
||||
}
|
||||
}
|
||||
|
||||
public void insert(FileExportTask task) { mapper.insert(task); }
|
||||
public void initializeDispatchTime(long id) { mapper.initializeDispatchTime(id); }
|
||||
|
||||
public boolean claim(long id, int dispatchVersion, String owner, int leaseSeconds, int durationMinutes) {
|
||||
return mapper.claim(id, dispatchVersion, owner, leaseSeconds, durationMinutes) == 1;
|
||||
}
|
||||
|
||||
public boolean heartbeat(long id, int dispatchVersion, String owner, int leaseSeconds) {
|
||||
return mapper.heartbeat(id, dispatchVersion, owner, leaseSeconds) == 1;
|
||||
}
|
||||
|
||||
public boolean progress(long id, int dispatchVersion, String owner, long rows) {
|
||||
return mapper.progress(id, dispatchVersion, owner, rows) == 1;
|
||||
}
|
||||
|
||||
public boolean succeed(long id, int dispatchVersion, String owner, String fileName, String objectKey,
|
||||
String contentType, long sizeBytes, long rows, int retentionDays) {
|
||||
return mapper.succeed(id, dispatchVersion, owner, fileName, objectKey, contentType,
|
||||
sizeBytes, rows, retentionDays) == 1;
|
||||
}
|
||||
|
||||
public boolean scheduleRetry(long id, int dispatchVersion, String owner, int backoffSeconds, String message) {
|
||||
return mapper.scheduleRetry(id, dispatchVersion, owner, backoffSeconds, message) == 1;
|
||||
}
|
||||
|
||||
public boolean fail(long id, int dispatchVersion, String owner, String message) {
|
||||
return mapper.fail(id, dispatchVersion, owner, message) == 1;
|
||||
}
|
||||
public boolean manualRetry(long id) { return mapper.manualRetry(id) == 1; }
|
||||
public boolean failQuarantined(long id, int dispatchVersion, String reason) { return mapper.failQuarantined(id, dispatchVersion, reason) == 1; }
|
||||
public boolean recoverStale(long id, int dispatchVersion) { return mapper.recoverStale(id, dispatchVersion) == 1; }
|
||||
public boolean failStale(long id, int dispatchVersion) { return mapper.failStale(id, dispatchVersion) == 1; }
|
||||
public List<FileExportTask> expiredArtifactsDb() { return mapper.expiredArtifactsDb(); }
|
||||
public long activeForUser(long tenantId,String appCode,long requestedBy){return mapper.activeForUser(tenantId,appCode,requestedBy);}
|
||||
public long activeForType(String exportType){return mapper.activeForType(exportType);}
|
||||
public FileExportTask recentDuplicate(long tenantId,String appCode,long requestedBy,String exportType,String json){return mapper.recentDuplicate(tenantId,appCode,requestedBy,exportType,json);}
|
||||
public boolean downloadable(long id){return mapper.downloadable(id)>0;}
|
||||
public boolean succeededObjectExists(String objectKey){return mapper.succeededObjectExists(objectKey)>0;}
|
||||
|
||||
public List<FileExportTask> staleRunning() { return mapper.staleRunning(); }
|
||||
public List<FileExportTask> missingOutbox(int graceSeconds) { return mapper.missingOutbox(graceSeconds); }
|
||||
|
||||
public FileExportTask find(long id) { return mapper.selectById(id); }
|
||||
|
||||
@@ -43,86 +97,6 @@ public class ExportTaskRepository {
|
||||
return PageResult.from(mapper.selectPage(new Page<>(page, size), query));
|
||||
}
|
||||
|
||||
public Long firstPendingId() {
|
||||
FileExportTask task = mapper.selectOne(new LambdaQueryWrapper<FileExportTask>()
|
||||
.select(FileExportTask::getId)
|
||||
.eq(FileExportTask::getStatus, "PENDING")
|
||||
.orderByAsc(FileExportTask::getRequestedAt)
|
||||
.orderByAsc(FileExportTask::getId)
|
||||
.last("LIMIT 1"));
|
||||
return task == null ? null : task.getId();
|
||||
}
|
||||
|
||||
public boolean claim(long id, LocalDateTime now) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getId, id)
|
||||
.eq(FileExportTask::getStatus, "PENDING")
|
||||
.set(FileExportTask::getStatus, "RUNNING")
|
||||
.set(FileExportTask::getStartedAt, now)
|
||||
.set(FileExportTask::getFailureMessage, null)) == 1;
|
||||
}
|
||||
|
||||
public void progress(long id, long rows) {
|
||||
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getId, id)
|
||||
.eq(FileExportTask::getStatus, "RUNNING")
|
||||
.set(FileExportTask::getProcessedRows, rows));
|
||||
}
|
||||
|
||||
public boolean succeed(long id, String fileName, String objectKey, String contentType,
|
||||
long sizeBytes, long rows, LocalDateTime completedAt, LocalDateTime expiresAt) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getId, id)
|
||||
.eq(FileExportTask::getStatus, "RUNNING")
|
||||
.set(FileExportTask::getStatus, "SUCCEEDED")
|
||||
.set(FileExportTask::getFileName, fileName)
|
||||
.set(FileExportTask::getObjectKey, objectKey)
|
||||
.set(FileExportTask::getContentType, contentType)
|
||||
.set(FileExportTask::getSizeBytes, sizeBytes)
|
||||
.set(FileExportTask::getTotalRows, rows)
|
||||
.set(FileExportTask::getProcessedRows, rows)
|
||||
.set(FileExportTask::getCompletedAt, completedAt)
|
||||
.set(FileExportTask::getExpiresAt, expiresAt)) == 1;
|
||||
}
|
||||
|
||||
public void fail(long id, String message, LocalDateTime completedAt) {
|
||||
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getId, id)
|
||||
.eq(FileExportTask::getStatus, "RUNNING")
|
||||
.set(FileExportTask::getStatus, "FAILED")
|
||||
.set(FileExportTask::getFailureMessage, message)
|
||||
.set(FileExportTask::getCompletedAt, completedAt));
|
||||
}
|
||||
|
||||
public boolean retry(long id) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getId, id)
|
||||
.eq(FileExportTask::getStatus, "FAILED")
|
||||
.set(FileExportTask::getStatus, "PENDING")
|
||||
.set(FileExportTask::getProcessedRows, 0L)
|
||||
.set(FileExportTask::getTotalRows, null)
|
||||
.set(FileExportTask::getFailureMessage, null)
|
||||
.set(FileExportTask::getStartedAt, null)
|
||||
.set(FileExportTask::getCompletedAt, null)) == 1;
|
||||
}
|
||||
|
||||
public int requeueStale(LocalDateTime cutoff) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getStatus, "RUNNING")
|
||||
.lt(FileExportTask::getUpdatedAt, cutoff)
|
||||
.set(FileExportTask::getStatus, "PENDING")
|
||||
.set(FileExportTask::getStartedAt, null)
|
||||
.set(FileExportTask::getFailureMessage, "Previous worker stopped before completion; task requeued"));
|
||||
}
|
||||
|
||||
public List<FileExportTask> expiredArtifacts(LocalDateTime now) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getStatus, "SUCCEEDED")
|
||||
.le(FileExportTask::getExpiresAt, now)
|
||||
.isNotNull(FileExportTask::getObjectKey)
|
||||
.last("LIMIT 50"));
|
||||
}
|
||||
|
||||
public void markExpired(long id) {
|
||||
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
|
||||
.eq(FileExportTask::getId, id)
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.client.IamExportClient;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.IndexedColors;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class CustomerExportTaskHandler implements ExportTaskHandler {
|
||||
static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final List<String> HEADERS = List.of(
|
||||
"客户ID", "客户编码", "公司名称", "营业执照企业名称", "统一社会信用代码", "企业类型",
|
||||
"成立日期", "注册资本", "币种", "注册地址", "联系人", "联系电话", "联系邮箱", "联系微信",
|
||||
"销售人员", "客户套餐", "账期(天)", "状态", "入驻时间", "审批时间");
|
||||
|
||||
private final IamExportClient iamClient;
|
||||
private final LocalExportStorage storage;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String internalToken;
|
||||
|
||||
public CustomerExportTaskHandler(IamExportClient iamClient, LocalExportStorage storage,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.iamClient = iamClient;
|
||||
this.storage = storage;
|
||||
this.objectMapper = objectMapper;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
@Override public String exportType() { return "CUSTOMER_LIST"; }
|
||||
@Override public String requiredPermission() { return "platform:customer:export"; }
|
||||
|
||||
@Override
|
||||
public void validateParameters(Map<String, Object> parameters) {
|
||||
if (parameters == null) return;
|
||||
Object keyword = parameters.get("keyword");
|
||||
if (keyword != null && String.valueOf(keyword).length() > 128) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出关键词不能超过 128 个字符");
|
||||
}
|
||||
Object status = parameters.get("status");
|
||||
if (status != null && !String.valueOf(status).isBlank()
|
||||
&& !Set.of("PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED")
|
||||
.contains(String.valueOf(status))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出状态无效");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExportResult export(FileExportTask task, ProgressReporter progress) {
|
||||
CustomerParameters parameters = parameters(task.getParametersJson());
|
||||
LocalExportStorage.PendingFile target = storage.create(task.getId());
|
||||
long rows = 0;
|
||||
try (target; SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
|
||||
workbook.setCompressTempFiles(true);
|
||||
Sheet sheet = workbook.createSheet("客户列表");
|
||||
writeHeader(workbook, sheet);
|
||||
Long cursor = null;
|
||||
boolean hasMore;
|
||||
do {
|
||||
var response = iamClient.customers(internalToken, cursor, 500,
|
||||
blankToNull(parameters.keyword()), blankToNull(parameters.status()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM customer export is unavailable");
|
||||
}
|
||||
var batch = response.data();
|
||||
for (var item : batch.records()) writeRow(sheet.createRow(Math.toIntExact(++rows)), item);
|
||||
progress.update(rows);
|
||||
hasMore = batch.hasMore();
|
||||
if (hasMore && (batch.nextCursor() == null || batch.nextCursor().equals(cursor))) {
|
||||
throw new IllegalStateException("IAM customer export cursor did not advance");
|
||||
}
|
||||
cursor = batch.nextCursor();
|
||||
} while (hasMore);
|
||||
for (int index = 0; index < HEADERS.size(); index++) sheet.setColumnWidth(index, columnWidth(index));
|
||||
workbook.write(target.output());
|
||||
} catch (IOException exception) {
|
||||
storage.delete(target.objectKey());
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户导出文件生成失败");
|
||||
} catch (RuntimeException exception) {
|
||||
storage.delete(target.objectKey());
|
||||
throw exception;
|
||||
}
|
||||
String fileName = "客户列表-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
|
||||
return new ExportResult(fileName, target.objectKey(), CONTENT_TYPE,
|
||||
storage.size(target.objectKey()), rows);
|
||||
}
|
||||
|
||||
private CustomerParameters parameters(String json) {
|
||||
try { return objectMapper.readValue(json, CustomerParameters.class); }
|
||||
catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "客户导出筛选条件无效"); }
|
||||
}
|
||||
|
||||
private void writeHeader(Workbook workbook, Sheet sheet) {
|
||||
CellStyle style = workbook.createCellStyle();
|
||||
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
|
||||
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
var font = workbook.createFont();
|
||||
font.setBold(true);
|
||||
style.setFont(font);
|
||||
Row row = sheet.createRow(0);
|
||||
for (int index = 0; index < HEADERS.size(); index++) {
|
||||
var cell = row.createCell(index);
|
||||
cell.setCellValue(HEADERS.get(index));
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
sheet.createFreezePane(0, 1);
|
||||
}
|
||||
|
||||
private void writeRow(Row row, IamExportClient.CustomerExportRow item) {
|
||||
int column = 0;
|
||||
cell(row, column++, item.id()); cell(row, column++, item.tenantCode());
|
||||
cell(row, column++, item.tenantName()); cell(row, column++, item.legalName());
|
||||
cell(row, column++, item.unifiedSocialCreditCode()); cell(row, column++, item.enterpriseType());
|
||||
cell(row, column++, item.establishedDate()); cell(row, column++, item.registeredCapital());
|
||||
cell(row, column++, item.registeredCapitalCurrency()); cell(row, column++, item.registeredAddress());
|
||||
cell(row, column++, item.contactName()); cell(row, column++, item.contactPhone());
|
||||
cell(row, column++, item.contactEmail()); cell(row, column++, item.contactWechat());
|
||||
cell(row, column++, item.salesOwnerName()); cell(row, column++, item.servicePackage());
|
||||
cell(row, column++, item.paymentTermDays()); cell(row, column++, statusLabel(item.status()));
|
||||
cell(row, column++, item.createdAt()); cell(row, column, item.approvedAt());
|
||||
}
|
||||
|
||||
private void cell(Row row, int column, Object value) {
|
||||
row.createCell(column).setCellValue(value == null ? "" : String.valueOf(value));
|
||||
}
|
||||
|
||||
private int columnWidth(int index) {
|
||||
return switch (index) {
|
||||
case 2, 3, 9 -> 28 * 256;
|
||||
case 4, 12 -> 24 * 256;
|
||||
case 18, 19 -> 21 * 256;
|
||||
default -> 16 * 256;
|
||||
};
|
||||
}
|
||||
|
||||
private String statusLabel(String status) {
|
||||
if (status == null) return "";
|
||||
return switch (status) {
|
||||
case "PROFILE_REQUIRED" -> "待完善";
|
||||
case "PENDING" -> "待审核";
|
||||
case "ACTIVE" -> "启用";
|
||||
case "REJECTED" -> "已驳回";
|
||||
case "DISABLED" -> "已停用";
|
||||
default -> status;
|
||||
};
|
||||
}
|
||||
|
||||
private String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
|
||||
public record CustomerParameters(String keyword, String status) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportCommand;
|
||||
import com.baiyue.file.entity.FileExportOutbox;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class ExportCommandFactory {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String topic;
|
||||
public ExportCommandFactory(ObjectMapper objectMapper,
|
||||
@Value("${baiyue.file.export.rocketmq.topic:test-baiyue-file-export-command-v1}") String topic) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.topic = topic;
|
||||
}
|
||||
public FileExportOutbox create(FileExportTask task, int dispatchVersion) {
|
||||
String eventId = UUID.randomUUID().toString();
|
||||
ExportCommand command = new ExportCommand(1, eventId, String.valueOf(task.getId()), task.getExportType(),
|
||||
dispatchVersion, OffsetDateTime.now(), MDC.get("traceId"));
|
||||
FileExportOutbox row = new FileExportOutbox();
|
||||
row.setId(IdWorker.getId()); row.setEventId(eventId); row.setTaskId(task.getId());
|
||||
row.setDispatchVersion(dispatchVersion); row.setTopic(topic); row.setTag("EXPORT_TASK");
|
||||
row.setMessageKey(String.valueOf(task.getId())); row.setPayloadJson(json(command));
|
||||
row.setStatus("PENDING"); row.setAttemptCount(0);
|
||||
return row;
|
||||
}
|
||||
private String json(ExportCommand command) {
|
||||
try { return objectMapper.writeValueAsString(command); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("Unable to serialize export command", exception); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
public class ExportFailure extends RuntimeException {
|
||||
private final boolean retryable;
|
||||
public ExportFailure(String message, boolean retryable) { super(message); this.retryable = retryable; }
|
||||
public ExportFailure(String message, boolean retryable, Throwable cause) { super(message, cause); this.retryable = retryable; }
|
||||
public boolean retryable() { return retryable; }
|
||||
public static ExportFailure temporary(String message, Throwable cause) { return new ExportFailure(message, true, cause); }
|
||||
public static ExportFailure permanent(String message) { return new ExportFailure(message, false); }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ExportMetrics {
|
||||
private final MeterRegistry registry;
|
||||
public ExportMetrics(MeterRegistry registry, JdbcTemplate jdbc) {
|
||||
this.registry=registry;
|
||||
Gauge.builder("export_task_queue_age_seconds",jdbc,j->number(j,"SELECT COALESCE(TIMESTAMPDIFF(SECOND,MIN(requested_at),CURRENT_TIMESTAMP(3)),0) FROM file_export_task WHERE status='PENDING'")).register(registry);
|
||||
Gauge.builder("export_outbox_pending",jdbc,j->number(j,"SELECT COUNT(*) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry);
|
||||
Gauge.builder("export_outbox_oldest_age_seconds",jdbc,j->number(j,"SELECT COALESCE(TIMESTAMPDIFF(SECOND,MIN(created_at),CURRENT_TIMESTAMP(3)),0) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry);
|
||||
Gauge.builder("export_outbox_max_attempt",jdbc,j->number(j,"SELECT COALESCE(MAX(attempt_count),0) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry);
|
||||
Gauge.builder("export_storage_bytes",jdbc,j->number(j,"SELECT COALESCE(SUM(size_bytes),0) FROM file_export_task WHERE status='SUCCEEDED' AND expires_at>CURRENT_TIMESTAMP(3)")).register(registry);
|
||||
}
|
||||
public void created(String type){registry.counter("export_task_created_total","type",type).increment();}
|
||||
public void completed(String type,String status){registry.counter("export_task_completed_total","type",type,"status",status).increment();}
|
||||
private static double number(JdbcTemplate jdbc,String sql){try{Number value=jdbc.queryForObject(sql,Number.class);return value==null?0:value.doubleValue();}catch(RuntimeException ignored){return 0;}}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix="baiyue.file.export.reconciler", name="enabled", havingValue="true")
|
||||
public class ExportReconciler {
|
||||
private static final Logger LOGGER= LoggerFactory.getLogger(ExportReconciler.class);
|
||||
private final ExportTaskRepository tasks; private final ExportOutboxRepository outbox;
|
||||
private final ExportStateService states; private final ExportStorage storage; private final MeterRegistry meters;
|
||||
private final int graceSeconds, sentStaleSeconds;
|
||||
private final int retentionDays;
|
||||
public ExportReconciler(ExportTaskRepository tasks, ExportOutboxRepository outbox, ExportStateService states,
|
||||
ExportStorage storage, MeterRegistry meters,
|
||||
@Value("${baiyue.file.export.reconciler.redispatch-grace-seconds:60}") int graceSeconds,
|
||||
@Value("${baiyue.file.export.reconciler.sent-stale-seconds:600}") int sentStaleSeconds,
|
||||
@Value("${baiyue.file.export.retention-days:7}") int retentionDays) {
|
||||
this.tasks=tasks;this.outbox=outbox;this.states=states;this.storage=storage;this.meters=meters;
|
||||
this.graceSeconds=graceSeconds;this.sentStaleSeconds=sentStaleSeconds;this.retentionDays=retentionDays;
|
||||
}
|
||||
@Scheduled(cron="${baiyue.file.export.reconciler.outbox-cleanup-cron:0 15 3 * * *}")
|
||||
public void cleanupRetainedOutbox() {
|
||||
count("outbox_clean",outbox.deleteRetainedSent(retentionDays,5000));
|
||||
}
|
||||
@Scheduled(fixedDelayString="${baiyue.file.export.reconciler.poll-interval-ms:30000}")
|
||||
public void reconcile() {
|
||||
int released=outbox.releaseExpiredClaims(); count("outbox_release",released);
|
||||
int redispatched=outbox.redispatchSent(sentStaleSeconds,graceSeconds); count("redispatch",redispatched);
|
||||
int missing=0; for(FileExportTask task:tasks.missingOutbox(graceSeconds)) if(states.ensureOutbox(task)) missing++;
|
||||
count("redispatch",missing);
|
||||
int recovered=0; for(FileExportTask task:tasks.staleRunning()) if(states.recoverStale(task)) recovered++;
|
||||
count("lease_recover",recovered);
|
||||
int expired=0; for(FileExportTask task:tasks.expiredArtifactsDb()) {
|
||||
if(storage.delete(task.getObjectKey())) { tasks.markExpired(task.getId()); expired++; }
|
||||
else LOGGER.warn("导出文件过期清理失败 taskId={}",task.getId());
|
||||
}
|
||||
count("expire",expired);
|
||||
count("orphan_clean", storage.cleanupOrphans(Duration.ofHours(24), tasks::succeededObjectExists));
|
||||
}
|
||||
private void count(String action,int value){ if(value>0)meters.counter("export_reconciler_action_total","action",action).increment(value); }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
|
||||
@Service
|
||||
public class ExportStateService {
|
||||
private final ExportTaskRepository tasks;
|
||||
private final ExportOutboxRepository outbox;
|
||||
private final ExportCommandFactory commands;
|
||||
private final ExportMetrics metrics;
|
||||
public ExportStateService(ExportTaskRepository tasks, ExportOutboxRepository outbox, ExportCommandFactory commands,
|
||||
ExportMetrics metrics) {
|
||||
this.tasks = tasks; this.outbox = outbox; this.commands = commands; this.metrics = metrics;
|
||||
}
|
||||
@Transactional
|
||||
public CreationResult createConstrained(FileExportTask task) {
|
||||
tasks.lockCreationGuard();
|
||||
FileExportTask duplicate = tasks.recentDuplicate(task.getTenantId(), task.getAppCode(), task.getRequestedBy(),
|
||||
task.getExportType(), task.getParametersJson());
|
||||
if (duplicate != null) return new CreationResult(duplicate, false);
|
||||
if (tasks.activeForUser(task.getTenantId(), task.getAppCode(), task.getRequestedBy()) >= 3) {
|
||||
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "同一用户最多同时执行 3 个导出任务");
|
||||
}
|
||||
if (tasks.activeForType(task.getExportType()) >= 5) {
|
||||
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "该导出类型当前任务较多,请稍后重试");
|
||||
}
|
||||
tasks.insert(task);
|
||||
tasks.initializeDispatchTime(task.getId());
|
||||
if (!outbox.insert(commands.create(task, task.getDispatchVersion()))) {
|
||||
throw new IllegalStateException("Duplicate export command while creating task");
|
||||
}
|
||||
return new CreationResult(task, true);
|
||||
}
|
||||
@Transactional
|
||||
public boolean retryAfterFailure(FileExportTask current, String owner, int seconds, String message) {
|
||||
if (!tasks.scheduleRetry(current.getId(), current.getDispatchVersion(), owner, seconds, safe(message))) return false;
|
||||
FileExportTask refreshed = tasks.find(current.getId());
|
||||
if (!outbox.insert(commands.create(refreshed, refreshed.getDispatchVersion()))) {
|
||||
throw new IllegalStateException("Unable to create retry command");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Transactional
|
||||
public boolean fail(FileExportTask task, String owner, String message) {
|
||||
return tasks.fail(task.getId(), task.getDispatchVersion(), owner, safe(message));
|
||||
}
|
||||
@Transactional
|
||||
public boolean ensureOutbox(FileExportTask task) {
|
||||
return outbox.insert(commands.create(task, task.getDispatchVersion()));
|
||||
}
|
||||
@Transactional
|
||||
public boolean manualRetry(FileExportTask task) {
|
||||
if (!tasks.manualRetry(task.getId())) return false;
|
||||
FileExportTask refreshed = tasks.find(task.getId());
|
||||
if (!outbox.insert(commands.create(refreshed, refreshed.getDispatchVersion()))) {
|
||||
throw new IllegalStateException("Unable to create manual retry command");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@Transactional
|
||||
public void quarantine(String eventId, long taskId, int version, String reason) {
|
||||
FileExportTask current = tasks.find(taskId);
|
||||
if (current == null || current.getDispatchVersion() != version || !"PENDING".equals(current.getStatus())) return;
|
||||
boolean changed = outbox.quarantine(eventId, taskId, version, safe(reason));
|
||||
if (changed) {
|
||||
if (!tasks.failQuarantined(taskId, version, safe(reason))) {
|
||||
throw new IllegalStateException("Unable to fail quarantined export task");
|
||||
}
|
||||
metrics.completed(current.getExportType(), "FAILED");
|
||||
}
|
||||
}
|
||||
@Transactional
|
||||
public boolean recoverStale(FileExportTask task) {
|
||||
if (task.getAttemptCount() >= task.getMaxAttempts()) {
|
||||
boolean failed = tasks.failStale(task.getId(), task.getDispatchVersion());
|
||||
if (failed) metrics.completed(task.getExportType(), "FAILED");
|
||||
return failed;
|
||||
}
|
||||
if (!tasks.recoverStale(task.getId(), task.getDispatchVersion())) return false;
|
||||
FileExportTask refreshed = tasks.find(task.getId());
|
||||
if (!outbox.insert(commands.create(refreshed, refreshed.getDispatchVersion()))) {
|
||||
throw new IllegalStateException("Unable to create lease recovery command");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private String safe(String message) {
|
||||
String value = message == null || message.isBlank() ? "导出任务执行失败" : message;
|
||||
return value.length() > 1000 ? value.substring(0, 1000) : value;
|
||||
}
|
||||
public record CreationResult(FileExportTask task, boolean created) { }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public interface ExportStorage {
|
||||
PendingFile create(long taskId);
|
||||
long size(String objectKey);
|
||||
Resource load(String objectKey);
|
||||
boolean delete(String objectKey);
|
||||
int cleanupOrphans(Duration minimumAge, Predicate<String> referenced);
|
||||
|
||||
record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable {
|
||||
@Override public void close() throws IOException { output.close(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@Service
|
||||
public class ExportTaskExecutor {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ExportTaskExecutor.class);
|
||||
private final ExportTaskRepository tasks;
|
||||
private final ExportDefinitionRegistry definitions;
|
||||
private final GenericXlsxExportRunner runner;
|
||||
private final ExportStateService states;
|
||||
private final ExportStorage storage;
|
||||
private final MeterRegistry meters;
|
||||
private final int leaseSeconds, heartbeatSeconds, maxDurationMinutes, retentionDays;
|
||||
private final ScheduledExecutorService watchdogs = Executors.newScheduledThreadPool(4, runnable -> {
|
||||
Thread thread = new Thread(runnable, "file-export-watchdog"); thread.setDaemon(true); return thread;
|
||||
});
|
||||
|
||||
public ExportTaskExecutor(ExportTaskRepository tasks, ExportDefinitionRegistry definitions,
|
||||
GenericXlsxExportRunner runner, ExportStateService states, ExportStorage storage, MeterRegistry meters,
|
||||
@Value("${baiyue.file.export.task.lease-seconds:300}") int leaseSeconds,
|
||||
@Value("${baiyue.file.export.task.heartbeat-seconds:60}") int heartbeatSeconds,
|
||||
@Value("${baiyue.file.export.task.max-duration-minutes:30}") int maxDurationMinutes,
|
||||
@Value("${baiyue.file.export.retention-days:7}") int retentionDays) {
|
||||
this.tasks=tasks;this.definitions=definitions;this.runner=runner;this.states=states;this.storage=storage;this.meters=meters;
|
||||
this.leaseSeconds=leaseSeconds;this.heartbeatSeconds=heartbeatSeconds;this.maxDurationMinutes=maxDurationMinutes;this.retentionDays=retentionDays;
|
||||
}
|
||||
|
||||
public void execute(long taskId, int dispatchVersion, String owner) {
|
||||
if (!tasks.claim(taskId, dispatchVersion, owner, leaseSeconds, maxDurationMinutes)) return;
|
||||
FileExportTask task = tasks.find(taskId);
|
||||
AtomicBoolean abort = new AtomicBoolean();
|
||||
ScheduledFuture<?> watchdog = watchdogs.scheduleAtFixedRate(() -> heartbeat(taskId, dispatchVersion, owner, abort),
|
||||
heartbeatSeconds, heartbeatSeconds, TimeUnit.SECONDS);
|
||||
long started = System.nanoTime();
|
||||
GenericXlsxExportRunner.Result result = null;
|
||||
try {
|
||||
result = runner.run(task, definitions.require(task.getExportType()), abort,
|
||||
rows -> { if (!tasks.progress(taskId, dispatchVersion, owner, rows)) abort.set(true); });
|
||||
if (!tasks.succeed(taskId, dispatchVersion, owner, result.fileName(), result.objectKey(),
|
||||
result.contentType(), result.sizeBytes(), result.rows(), retentionDays)) {
|
||||
deleteOrWarn(result.objectKey(), taskId, "最终状态提交未生效");
|
||||
FileExportTask latest = tasks.find(taskId);
|
||||
if (latest != null && "RUNNING".equals(latest.getStatus())
|
||||
&& latest.getDispatchVersion() == dispatchVersion && owner.equals(latest.getLeaseOwner())) {
|
||||
retryOrFail(latest, dispatchVersion, owner, ExportFailure.temporary("导出执行超过最大时长 " + maxDurationMinutes + " 分钟", null));
|
||||
}
|
||||
} else {
|
||||
meters.counter("export_task_completed_total", "type", task.getExportType(), "status", "SUCCEEDED").increment();
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
if (result != null) deleteOrWarn(result.objectKey(), taskId, "执行异常");
|
||||
retryOrFail(tasks.find(taskId), dispatchVersion, owner, exception);
|
||||
LOGGER.error("导出任务执行失败 taskId={} dispatchVersion={}", taskId, dispatchVersion, exception);
|
||||
} finally {
|
||||
watchdog.cancel(true);
|
||||
meters.timer("export_task_duration_seconds", "type", task.getExportType())
|
||||
.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void heartbeat(long id, int version, String owner, AtomicBoolean abort) {
|
||||
try { if (!tasks.heartbeat(id, version, owner, leaseSeconds)) abort.set(true); }
|
||||
catch (RuntimeException exception) { abort.set(true); LOGGER.error("导出 watchdog 续租失败 taskId={}", id, exception); }
|
||||
}
|
||||
|
||||
private void deleteOrWarn(String objectKey, long taskId, String reason) {
|
||||
try {
|
||||
if (!storage.delete(objectKey)) {
|
||||
LOGGER.warn("导出结果文件清理失败 taskId={} objectKey={} reason={}", taskId, objectKey, reason);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.warn("导出结果文件清理异常 taskId={} objectKey={} reason={}", taskId, objectKey, reason, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void retryOrFail(FileExportTask task, int expectedVersion, String owner, RuntimeException exception) {
|
||||
if (task == null || !"RUNNING".equals(task.getStatus()) || task.getDispatchVersion() != expectedVersion
|
||||
|| !owner.equals(task.getLeaseOwner())) return;
|
||||
boolean retryable = !(exception instanceof ExportFailure failure) || failure.retryable();
|
||||
String message = exception.getMessage() == null ? "导出任务执行失败" : exception.getMessage();
|
||||
if (retryable && task.getAttemptCount() < task.getMaxAttempts()) {
|
||||
int backoff = jitteredBackoffSeconds(task.getAttemptCount() <= 1 ? 60 : 900);
|
||||
if (states.retryAfterFailure(task, owner, backoff, message)) return;
|
||||
}
|
||||
if (states.fail(task, owner, message)) {
|
||||
meters.counter("export_task_completed_total", "type", task.getExportType(), "status", "FAILED").increment();
|
||||
}
|
||||
}
|
||||
|
||||
static int jitteredBackoffSeconds(int baseSeconds) {
|
||||
int spread = Math.max(1, baseSeconds / 5);
|
||||
return ThreadLocalRandom.current().nextInt(baseSeconds - spread, baseSeconds + spread + 1);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdownWatchdogs() {
|
||||
watchdogs.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ExportTaskHandler {
|
||||
String exportType();
|
||||
String requiredPermission();
|
||||
default void validateParameters(Map<String, Object> parameters) { }
|
||||
ExportResult export(FileExportTask task, ProgressReporter progress);
|
||||
|
||||
record ExportResult(String fileName, String objectKey, String contentType, long sizeBytes, long rows) { }
|
||||
|
||||
@FunctionalInterface
|
||||
interface ProgressReporter { void update(long rows); }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
|
||||
import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -15,38 +16,45 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.TreeMap;
|
||||
|
||||
@Service
|
||||
public class ExportTaskService {
|
||||
private final ExportTaskRepository repository;
|
||||
private final LocalExportStorage storage;
|
||||
private final ExportStorage storage;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Map<String, ExportTaskHandler> handlers;
|
||||
private final ExportDefinitionRegistry registry;
|
||||
private final ExportStateService stateService;
|
||||
private final ExportMetrics metrics;
|
||||
|
||||
public ExportTaskService(ExportTaskRepository repository, LocalExportStorage storage,
|
||||
ObjectMapper objectMapper, List<ExportTaskHandler> handlers) {
|
||||
public ExportTaskService(ExportTaskRepository repository, ExportStorage storage,
|
||||
ObjectMapper objectMapper, ExportDefinitionRegistry registry,
|
||||
ExportStateService stateService, ExportMetrics metrics) {
|
||||
this.repository = repository;
|
||||
this.storage = storage;
|
||||
this.objectMapper = objectMapper;
|
||||
this.handlers = handlers.stream().collect(Collectors.toUnmodifiableMap(
|
||||
handler -> handler.exportType().toUpperCase(Locale.ROOT), Function.identity()));
|
||||
this.registry = registry;
|
||||
this.stateService = stateService;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) {
|
||||
requirePlatform(user);
|
||||
String type = request.exportType().trim().toUpperCase(Locale.ROOT);
|
||||
String format = request.format().trim().toUpperCase(Locale.ROOT);
|
||||
ExportTaskHandler handler = handler(type);
|
||||
var definition = registry.require(type);
|
||||
if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式");
|
||||
if (!user.permissions().contains(handler.requiredPermission())) {
|
||||
String permission = definition.getRequiredPermission();
|
||||
if (!definition.isEnabled() || !definition.getAppScope().equalsIgnoreCase(user.appCode())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "该导出类型当前不可用");
|
||||
}
|
||||
if (!user.permissions().contains(permission)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
||||
}
|
||||
handler.validateParameters(request.parameters() == null ? Map.of() : request.parameters());
|
||||
validateParameters(request.parameters());
|
||||
String parametersJson = parametersJson(request.parameters());
|
||||
long id = IdWorker.getId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FileExportTask task = new FileExportTask();
|
||||
@@ -57,14 +65,19 @@ public class ExportTaskService {
|
||||
task.setExportType(type);
|
||||
task.setFormat(format);
|
||||
task.setTaskName(request.taskName().trim());
|
||||
task.setParametersJson(parametersJson(request.parameters()));
|
||||
task.setParametersJson(parametersJson);
|
||||
task.setStatus("PENDING");
|
||||
task.setProcessedRows(0L);
|
||||
task.setDispatchVersion(1);
|
||||
task.setAttemptCount(0);
|
||||
task.setMaxAttempts(definition.getMaxAttempts());
|
||||
task.setNextRetryAt(now);
|
||||
task.setRequestedBy(user.userId());
|
||||
task.setRequestedUsername(user.username());
|
||||
task.setRequestedAt(now);
|
||||
repository.insert(task);
|
||||
return response(task);
|
||||
ExportStateService.CreationResult result = stateService.createConstrained(task);
|
||||
if (result.created()) metrics.created(type);
|
||||
return response(result.task());
|
||||
}
|
||||
|
||||
public PageResult<ExportTaskResponse> page(AuthenticatedUser user, long page, long size,
|
||||
@@ -90,7 +103,7 @@ public class ExportTaskService {
|
||||
if (!"SUCCEEDED".equals(task.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期");
|
||||
}
|
||||
if (task.getExpiresAt() != null && !task.getExpiresAt().isAfter(LocalDateTime.now())) {
|
||||
if (!repository.downloadable(id)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "导出文件已过期");
|
||||
}
|
||||
return new DownloadResource(storage.load(task.getObjectKey()), task.getFileName(), task.getContentType());
|
||||
@@ -98,22 +111,25 @@ public class ExportTaskService {
|
||||
|
||||
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
|
||||
FileExportTask task = ownedTask(user, id);
|
||||
ExportTaskHandler handler = handler(task.getExportType());
|
||||
if (!user.permissions().contains(handler.requiredPermission())) {
|
||||
if (!"FAILED".equals(task.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
|
||||
}
|
||||
var definition = registry.require(task.getExportType());
|
||||
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
|
||||
String permission = definition.getRequiredPermission();
|
||||
if (!user.permissions().contains(permission)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
||||
}
|
||||
if (!"FAILED".equals(task.getStatus()) || !repository.retry(id)) {
|
||||
if (!storage.delete(task.getObjectKey())) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "上次导出文件清理失败,请稍后重试");
|
||||
}
|
||||
boolean retried = stateService.manualRetry(task);
|
||||
if (!retried) {
|
||||
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
|
||||
}
|
||||
return response(repository.find(id));
|
||||
}
|
||||
|
||||
ExportTaskHandler handler(String exportType) {
|
||||
ExportTaskHandler handler = handlers.get(exportType == null ? "" : exportType.toUpperCase(Locale.ROOT));
|
||||
if (handler == null) throw new BizException(ResultCode.VALIDATION_FAILED, "不支持的导出类型");
|
||||
return handler;
|
||||
}
|
||||
|
||||
private FileExportTask ownedTask(AuthenticatedUser user, long id) {
|
||||
requirePlatform(user);
|
||||
FileExportTask task = repository.find(id);
|
||||
@@ -128,10 +144,47 @@ public class ExportTaskService {
|
||||
}
|
||||
|
||||
private String parametersJson(Map<String, Object> parameters) {
|
||||
try { return objectMapper.writeValueAsString(parameters == null ? Map.of() : parameters); }
|
||||
try { return objectMapper.writeValueAsString(parameters == null ? Map.of() : new TreeMap<>(parameters)); }
|
||||
catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); }
|
||||
}
|
||||
|
||||
private void validateParameters(Map<String, Object> parameters) {
|
||||
Map<String, Object> values = parameters == null ? Map.of() : parameters;
|
||||
if (values.size() > 20) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件最多 20 项");
|
||||
values.forEach((key, value) -> {
|
||||
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
|
||||
validateParameterValue(value, 1);
|
||||
});
|
||||
try {
|
||||
if (objectMapper.writeValueAsBytes(values).length > 4096) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件不能超过 4KB");
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateParameterValue(Object value, int depth) {
|
||||
if (depth > 2) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件嵌套不能超过 2 层");
|
||||
if (value == null || value instanceof Number || value instanceof Boolean) return;
|
||||
if (value instanceof String text) {
|
||||
if (text.length() > 256) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值最多 256 字符");
|
||||
return;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
map.forEach((key, nested) -> {
|
||||
if (!(key instanceof String text) || text.isBlank() || text.length() > 64) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
|
||||
}
|
||||
validateParameterValue(nested, depth + 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value instanceof Iterable<?> values) {
|
||||
values.forEach(nested -> validateParameterValue(nested, depth + 1));
|
||||
return;
|
||||
}
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效");
|
||||
}
|
||||
|
||||
private ExportTaskResponse response(FileExportTask task) {
|
||||
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
|
||||
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class ExportTaskWorker {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ExportTaskWorker.class);
|
||||
private final ExportTaskRepository repository;
|
||||
private final ExportTaskService service;
|
||||
private final LocalExportStorage storage;
|
||||
private final long retentionDays;
|
||||
private final long staleRunningMinutes;
|
||||
|
||||
public ExportTaskWorker(ExportTaskRepository repository, ExportTaskService service,
|
||||
LocalExportStorage storage,
|
||||
@Value("${baiyue.file.export.retention-days:7}") long retentionDays,
|
||||
@Value("${baiyue.file.export.stale-running-minutes:30}") long staleRunningMinutes) {
|
||||
this.repository = repository;
|
||||
this.service = service;
|
||||
this.storage = storage;
|
||||
this.retentionDays = Math.max(1, retentionDays);
|
||||
this.staleRunningMinutes = Math.max(5, staleRunningMinutes);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.file.export.worker-delay-ms:2000}")
|
||||
public void poll() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
repository.requeueStale(now.minusMinutes(staleRunningMinutes));
|
||||
expire(now);
|
||||
processNext();
|
||||
}
|
||||
|
||||
public boolean processNext() {
|
||||
Long id = repository.firstPendingId();
|
||||
if (id == null || !repository.claim(id, LocalDateTime.now())) return false;
|
||||
FileExportTask task = repository.find(id);
|
||||
try {
|
||||
ExportTaskHandler handler = service.handler(task.getExportType());
|
||||
var result = handler.export(task, rows -> repository.progress(id, rows));
|
||||
LocalDateTime completed = LocalDateTime.now();
|
||||
if (!repository.succeed(id, result.fileName(), result.objectKey(), result.contentType(),
|
||||
result.sizeBytes(), result.rows(), completed, completed.plusDays(retentionDays))) {
|
||||
storage.delete(result.objectKey());
|
||||
throw new IllegalStateException("Export task state changed before completion");
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
String message = safeMessage(exception);
|
||||
repository.fail(id, message, LocalDateTime.now());
|
||||
LOGGER.error("导出任务执行失败,taskId={},exportType={}", id, task.getExportType(), exception);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void expire(LocalDateTime now) {
|
||||
for (FileExportTask task : repository.expiredArtifacts(now)) {
|
||||
storage.delete(task.getObjectKey());
|
||||
repository.markExpired(task.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private String safeMessage(Exception exception) {
|
||||
String message = exception instanceof BizException ? exception.getMessage() : "导出任务执行失败,请稍后重试";
|
||||
if (message == null || message.isBlank()) message = "导出任务执行失败";
|
||||
return message.length() <= 1000 ? message : message.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||
import com.baiyue.file.client.ExportProviderClient;
|
||||
import com.baiyue.file.client.ExportProviderResponseTooLargeException;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry.Definition;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.LongConsumer;
|
||||
|
||||
@Service
|
||||
public class GenericXlsxExportRunner {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(GenericXlsxExportRunner.class);
|
||||
public static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final int EXCEL_SHEET_ROWS = 1_048_575;
|
||||
private final ExportProviderClient provider;
|
||||
private final ExportStorage storage;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String internalToken;
|
||||
private final long maxFileBytes;
|
||||
|
||||
public GenericXlsxExportRunner(ExportProviderClient provider, ExportStorage storage, ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||
@Value("${baiyue.file.export.task.max-file-size-mb:50}") long maxFileSizeMb) {
|
||||
this.provider = provider; this.storage = storage; this.objectMapper = objectMapper;
|
||||
this.internalToken = internalToken; this.maxFileBytes = Math.max(1, maxFileSizeMb) * 1024L * 1024L;
|
||||
}
|
||||
|
||||
public Result run(FileExportTask task, Definition definition, AtomicBoolean abort, LongConsumer progress) {
|
||||
Map<String, Object> parameters = parameters(task.getParametersJson());
|
||||
ExportStorage.PendingFile target = storage.create(task.getId());
|
||||
long rows = 0;
|
||||
String cursor = null, snapshot = null, schemaHash = null;
|
||||
List<ExportColumn> columns = null;
|
||||
Set<String> columnKeys = Set.of();
|
||||
Set<String> warnedUnknownColumns = new HashSet<>();
|
||||
Set<String> warnedTruncatedColumns = new HashSet<>();
|
||||
try (target; SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
|
||||
workbook.setCompressTempFiles(true);
|
||||
WorkbookStyles styles = new WorkbookStyles(workbook);
|
||||
SheetState sheet = null;
|
||||
while (true) {
|
||||
checkAbort(abort);
|
||||
ExportBatchRequest request = new ExportBatchRequest(String.valueOf(task.getId()),
|
||||
String.valueOf(task.getTenantId()), task.getAppCode(), String.valueOf(task.getRequestedBy()),
|
||||
task.getDispatchVersion(), parameters, cursor, snapshot, definition.getBatchSize());
|
||||
ApiResponse<ExportBatchResponse> response;
|
||||
try { response = provider.batch(internalToken, task.getExportType(), definition, request); }
|
||||
catch (RuntimeException exception) {
|
||||
if (hasCause(exception, ExportProviderResponseTooLargeException.class)) {
|
||||
throw ExportFailure.permanent("Provider 单批响应超过 4MB");
|
||||
}
|
||||
throw ExportFailure.temporary("导出数据服务暂不可用", exception);
|
||||
}
|
||||
if (response == null) throw ExportFailure.temporary("导出数据服务无响应", null);
|
||||
if (!response.successful()) {
|
||||
String message = response.message() == null ? "导出数据服务返回失败" : response.message();
|
||||
if (response.code() != null && response.code().startsWith("S")) throw ExportFailure.temporary(message, null);
|
||||
throw ExportFailure.permanent(message);
|
||||
}
|
||||
if (response.data() == null) throw ExportFailure.permanent("Provider 成功响应缺少 data");
|
||||
ExportBatchResponse batch = response.data();
|
||||
try { if (objectMapper.writeValueAsBytes(batch).length > 4 * 1024 * 1024) throw ExportFailure.permanent("Provider 单批响应超过 4MB"); }
|
||||
catch (IOException exception) { throw ExportFailure.permanent("Provider 响应无法序列化"); }
|
||||
if (batch.rows() == null || batch.rows().size() > definition.getBatchSize()) throw ExportFailure.permanent("Provider 单批数据超过限制");
|
||||
if (batch.totalRows() != null && batch.totalRows() > definition.getMaxRows()) throw ExportFailure.permanent("导出预计行数超过最大限制 " + definition.getMaxRows());
|
||||
if (schemaHash == null) {
|
||||
if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
|
||||
throw ExportFailure.permanent("Provider 首批契约无效");
|
||||
}
|
||||
columns = validateColumns(batch.columns()); columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
|
||||
sheet = newSheet(workbook, 1, columns);
|
||||
} else if (!schemaHash.equals(batch.schemaHash())) {
|
||||
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
|
||||
}
|
||||
for (Map<String, Object> values : batch.rows()) {
|
||||
if (abort.get()) throw ExportFailure.temporary("导出执行租约已失效", null);
|
||||
if (++rows > definition.getMaxRows()) throw ExportFailure.permanent("导出行数超过最大限制 " + definition.getMaxRows());
|
||||
if (sheet.rowIndex > EXCEL_SHEET_ROWS) sheet = newSheet(workbook, sheet.index + 1, columns);
|
||||
writeRow(sheet.sheet.createRow(sheet.rowIndex++), columns, columnKeys, values, styles, warnedUnknownColumns, warnedTruncatedColumns);
|
||||
if (rows % 10_000 == 0) checkAbort(abort);
|
||||
}
|
||||
progress.accept(rows);
|
||||
if (!batch.hasMore()) break;
|
||||
if (blank(batch.nextCursor()) || batch.nextCursor().equals(cursor)) throw ExportFailure.temporary("Provider 游标未推进", null);
|
||||
cursor = batch.nextCursor();
|
||||
}
|
||||
checkAbort(abort);
|
||||
workbook.write(new SizeLimitedOutputStream(target.output(), maxFileBytes));
|
||||
} catch (SizeLimitedOutputStream.ExportSizeExceededException exception) {
|
||||
deleteOrWarn(target.objectKey(), task.getId(), "文件超过大小上限"); throw ExportFailure.permanent(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
deleteOrWarn(target.objectKey(), task.getId(), "文件写入失败"); throw ExportFailure.temporary("导出文件写入失败", exception);
|
||||
} catch (RuntimeException exception) {
|
||||
deleteOrWarn(target.objectKey(), task.getId(), "导出生成失败"); throw exception;
|
||||
}
|
||||
String name = task.getTaskName() + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
|
||||
try {
|
||||
return new Result(name, target.objectKey(), CONTENT_TYPE, storage.size(target.objectKey()), rows);
|
||||
} catch (RuntimeException exception) {
|
||||
deleteOrWarn(target.objectKey(), task.getId(), "读取最终文件大小失败");
|
||||
throw ExportFailure.temporary("导出文件读取失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteOrWarn(String objectKey, long taskId, String reason) {
|
||||
try {
|
||||
if (!storage.delete(objectKey)) {
|
||||
LOGGER.warn("导出临时文件清理失败 taskId={} objectKey={} reason={}", taskId, objectKey, reason);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.warn("导出临时文件清理异常 taskId={} objectKey={} reason={}", taskId, objectKey, reason, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ExportColumn> validateColumns(List<ExportColumn> columns) {
|
||||
if (columns == null || columns.isEmpty() || columns.size() > 100) throw ExportFailure.permanent("Provider 导出列定义无效");
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (ExportColumn column : columns) {
|
||||
if (column == null || blank(column.key()) || blank(column.title()) || column.type() == null || !keys.add(column.key())) {
|
||||
throw ExportFailure.permanent("Provider 导出列定义无效");
|
||||
}
|
||||
}
|
||||
return List.copyOf(columns);
|
||||
}
|
||||
|
||||
private SheetState newSheet(SXSSFWorkbook workbook, int index, List<ExportColumn> columns) {
|
||||
Sheet sheet = workbook.createSheet(index == 1 ? "导出数据" : "导出数据-" + index);
|
||||
CellStyle header = workbook.createCellStyle(); header.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
Font font = workbook.createFont(); font.setBold(true); header.setFont(font);
|
||||
Row row = sheet.createRow(0);
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
Cell cell = row.createCell(i); cell.setCellValue(columns.get(i).title()); cell.setCellStyle(header);
|
||||
sheet.setColumnWidth(i, Math.min(255, Math.max(8, columns.get(i).width() == null ? 18 : columns.get(i).width())) * 256);
|
||||
}
|
||||
sheet.createFreezePane(0, 1);
|
||||
return new SheetState(index, sheet, 1);
|
||||
}
|
||||
|
||||
private void writeRow(Row row, List<ExportColumn> columns, Set<String> columnKeys, Map<String, Object> values,
|
||||
WorkbookStyles styles, Set<String> warnedUnknownColumns, Set<String> warnedTruncatedColumns) {
|
||||
Map<String, Object> source = values == null ? Map.of() : values;
|
||||
source.keySet().stream().filter(key -> !columnKeys.contains(key) && warnedUnknownColumns.add(key))
|
||||
.forEach(key -> LOGGER.warn("Provider 返回未声明字段,已忽略 column={}", key));
|
||||
for (int i = 0; i < columns.size(); i++) writeCell(row.createCell(i), columns.get(i), source.get(columns.get(i).key()), styles, warnedTruncatedColumns);
|
||||
}
|
||||
|
||||
private void writeCell(Cell cell, ExportColumn column, Object value, WorkbookStyles styles, Set<String> warnedTruncatedColumns) {
|
||||
if (value == null) return;
|
||||
try {
|
||||
switch (column.type()) {
|
||||
case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns));
|
||||
case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact());
|
||||
case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue());
|
||||
case BOOLEAN -> cell.setCellValue(value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value)));
|
||||
case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); }
|
||||
case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); }
|
||||
}
|
||||
} catch (RuntimeException exception) { throw ExportFailure.permanent("Provider 字段类型与列定义不匹配: " + column.key()); }
|
||||
}
|
||||
|
||||
private String safeString(String value, String column, Set<String> warnedTruncatedColumns) {
|
||||
if (value.length() > 32767 && warnedTruncatedColumns.add(column)) {
|
||||
LOGGER.warn("Provider 字符串超过 Excel 单元格上限,已截断 column={}", column);
|
||||
}
|
||||
String text = value.length() > 32767 ? value.substring(0, 32767) : value;
|
||||
return !text.isEmpty() && "=+-@\t\r\n".indexOf(text.charAt(0)) >= 0 ? "'" + text : text;
|
||||
}
|
||||
private Map<String, Object> parameters(String json) {
|
||||
try { return objectMapper.readValue(json, new TypeReference<>() { }); }
|
||||
catch (Exception exception) { throw ExportFailure.permanent("导出筛选条件无效"); }
|
||||
}
|
||||
private void checkAbort(AtomicBoolean abort) { if (abort.get()) throw ExportFailure.temporary("导出执行超过最大时长或租约已失效", null); }
|
||||
private boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private boolean hasCause(Throwable error, Class<? extends Throwable> type) {
|
||||
for (Throwable current=error; current!=null; current=current.getCause()) if (type.isInstance(current)) return true;
|
||||
return false;
|
||||
}
|
||||
private static final class WorkbookStyles {
|
||||
final CellStyle date;
|
||||
final CellStyle dateTime;
|
||||
WorkbookStyles(Workbook workbook) {
|
||||
DataFormat formats = workbook.getCreationHelper().createDataFormat();
|
||||
date = workbook.createCellStyle(); date.setDataFormat(formats.getFormat("yyyy-mm-dd"));
|
||||
dateTime = workbook.createCellStyle(); dateTime.setDataFormat(formats.getFormat("yyyy-mm-dd hh:mm:ss"));
|
||||
}
|
||||
}
|
||||
private static final class SheetState { final int index; final Sheet sheet; int rowIndex; SheetState(int index, Sheet sheet, int rowIndex) { this.index=index;this.sheet=sheet;this.rowIndex=rowIndex; } }
|
||||
public record Result(String fileName, String objectKey, String contentType, long sizeBytes, long rows) { }
|
||||
}
|
||||
@@ -13,10 +13,14 @@ import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class LocalExportStorage {
|
||||
public class LocalExportStorage implements ExportStorage {
|
||||
private final Path root;
|
||||
private final Path exportRoot;
|
||||
|
||||
@@ -61,14 +65,35 @@ public class LocalExportStorage {
|
||||
catch (Exception exception) { throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); }
|
||||
}
|
||||
|
||||
public void delete(String objectKey) {
|
||||
if (objectKey == null || objectKey.isBlank()) return;
|
||||
public boolean delete(String objectKey) {
|
||||
if (objectKey == null || objectKey.isBlank()) return true;
|
||||
Path target = resolve(objectKey);
|
||||
try {
|
||||
Files.deleteIfExists(target);
|
||||
Path parent = target.getParent();
|
||||
if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) Files.deleteIfExists(parent);
|
||||
} catch (IOException ignored) { }
|
||||
} catch (IOException ignored) {
|
||||
return false;
|
||||
}
|
||||
Path parent = target.getParent();
|
||||
if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) {
|
||||
try { Files.deleteIfExists(parent); }
|
||||
catch (IOException ignored) { /* 文件已经删除,空目录清理失败不影响任务过期。 */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int cleanupOrphans(Duration minimumAge, Predicate<String> referenced) {
|
||||
int removed = 0;
|
||||
Instant cutoff = Instant.now().minus(minimumAge);
|
||||
try (Stream<Path> paths = Files.walk(exportRoot)) {
|
||||
for (Path path : paths.filter(Files::isRegularFile).toList()) {
|
||||
String objectKey = root.relativize(path).toString().replace('\\', '/');
|
||||
if (!objectKey.matches("exports/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) continue;
|
||||
if (Files.getLastModifiedTime(path).toInstant().isAfter(cutoff) || referenced.test(objectKey)) continue;
|
||||
if (delete(objectKey)) removed++;
|
||||
}
|
||||
} catch (IOException ignored) { /* 下一轮继续清理;存储指标和日志负责暴露持续故障。 */ }
|
||||
return removed;
|
||||
}
|
||||
|
||||
private Path resolve(String objectKey) {
|
||||
@@ -80,7 +105,4 @@ public class LocalExportStorage {
|
||||
return target;
|
||||
}
|
||||
|
||||
public record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable {
|
||||
@Override public void close() throws IOException { output.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.util.UUID;
|
||||
@Service
|
||||
public class LocalQualificationStorage {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
|
||||
private static final long MAX_SIZE = 10L * 1024 * 1024;
|
||||
private static final long MAX_SIZE = 5L * 1024 * 1024;
|
||||
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "application/pdf");
|
||||
private final Path root;
|
||||
private final Path qualificationRoot;
|
||||
@@ -53,7 +53,7 @@ public class LocalQualificationStorage {
|
||||
|
||||
public StoredFile store(long tenantId, MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件");
|
||||
if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 10 MB / 资质文件不能超过 10MB");
|
||||
if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB");
|
||||
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件");
|
||||
if (!matchesSignature(file, contentType)) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
final class SizeLimitedOutputStream extends FilterOutputStream {
|
||||
private final long limit;
|
||||
private long count;
|
||||
SizeLimitedOutputStream(OutputStream out, long limit) { super(out); this.limit = limit; }
|
||||
@Override public void write(int value) throws IOException { ensure(1); out.write(value); count++; }
|
||||
@Override public void write(byte[] bytes, int offset, int length) throws IOException { ensure(length); out.write(bytes, offset, length); count += length; }
|
||||
private void ensure(long added) throws IOException {
|
||||
if (count + added > limit) throw new ExportSizeExceededException("导出文件超过最大限制 " + limit + " 字节");
|
||||
}
|
||||
static final class ExportSizeExceededException extends IOException {
|
||||
ExportSizeExceededException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,30 @@ spring:
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config.namespace: ${NACOS_NAMESPACE:public}
|
||||
openfeign:
|
||||
client:
|
||||
config:
|
||||
iamExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
orderExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
warehouseExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
firstlegExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 11MB
|
||||
rocketmq:
|
||||
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||
producer:
|
||||
group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-publisher-v1
|
||||
send-message-timeout: 3000
|
||||
baiyue:
|
||||
security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
@@ -35,8 +55,43 @@ baiyue:
|
||||
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
|
||||
export:
|
||||
retention-days: ${FILE_EXPORT_RETENTION_DAYS:7}
|
||||
worker-delay-ms: ${FILE_EXPORT_WORKER_DELAY_MS:2000}
|
||||
stale-running-minutes: ${FILE_EXPORT_STALE_RUNNING_MINUTES:30}
|
||||
legacy-worker-enabled: false
|
||||
instance-id: ${HOSTNAME:file-local}
|
||||
task:
|
||||
lease-seconds: ${FILE_EXPORT_TASK_LEASE_SECONDS:300}
|
||||
heartbeat-seconds: ${FILE_EXPORT_TASK_HEARTBEAT_SECONDS:60}
|
||||
max-duration-minutes: ${FILE_EXPORT_MAX_DURATION_MINUTES:30}
|
||||
max-file-size-mb: ${FILE_EXPORT_MAX_FILE_SIZE_MB:50}
|
||||
publisher:
|
||||
enabled: ${FILE_EXPORT_PUBLISHER_ENABLED:false}
|
||||
poll-interval-ms: ${FILE_EXPORT_PUBLISHER_POLL_MS:5000}
|
||||
batch-size: 50
|
||||
lease-seconds: 60
|
||||
consumer:
|
||||
enabled: ${FILE_EXPORT_CONSUMER_ENABLED:false}
|
||||
concurrency: ${FILE_EXPORT_CONSUMER_CONCURRENCY:4}
|
||||
consume-timeout-minutes: ${FILE_EXPORT_CONSUME_TIMEOUT_MINUTES:90}
|
||||
max-reconsume-times: 2
|
||||
reconciler:
|
||||
enabled: ${FILE_EXPORT_RECONCILER_ENABLED:false}
|
||||
poll-interval-ms: ${FILE_EXPORT_RECONCILER_POLL_MS:30000}
|
||||
redispatch-grace-seconds: 60
|
||||
sent-stale-seconds: 600
|
||||
rocketmq:
|
||||
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-command-v1
|
||||
consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1
|
||||
dlq-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-file-export-worker-v1"
|
||||
dlq-consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-file-export-dlq-handler-v1
|
||||
definitions:
|
||||
CUSTOMER_LIST:
|
||||
provider-service: iam-service
|
||||
provider-path: /internal/exports/v1/CUSTOMER_LIST/batches
|
||||
required-permission: platform:customer:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
logging:
|
||||
|
||||
@@ -77,6 +77,14 @@ CREATE TABLE IF NOT EXISTS file_export_task (
|
||||
content_type VARCHAR(128) NULL COMMENT '文件类型',
|
||||
size_bytes BIGINT UNSIGNED NULL COMMENT '文件字节数',
|
||||
failure_message VARCHAR(1000) NULL COMMENT '失败原因',
|
||||
dispatch_version INT NOT NULL DEFAULT 1 COMMENT '派发版本,仅执行重试时递增',
|
||||
attempt_count INT NOT NULL DEFAULT 0 COMMENT '已执行尝试次数',
|
||||
max_attempts INT NOT NULL DEFAULT 3 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_heartbeat_at DATETIME(3) NULL COMMENT '最后心跳时间',
|
||||
execution_deadline_at DATETIME(3) NULL COMMENT '本次执行截止时间',
|
||||
requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请用户ID',
|
||||
requested_username VARCHAR(128) NULL COMMENT '申请账号快照',
|
||||
requested_at DATETIME(3) NOT NULL COMMENT '申请时间',
|
||||
@@ -93,5 +101,40 @@ CREATE TABLE IF NOT EXISTS file_export_task (
|
||||
UNIQUE KEY uk_file_export_task_no (task_no),
|
||||
KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at),
|
||||
KEY idx_file_export_status (status, requested_at),
|
||||
KEY idx_file_export_dispatch (status, next_retry_at, id),
|
||||
KEY idx_file_export_lease (status, lease_until),
|
||||
KEY idx_file_export_expiry (status, expires_at)
|
||||
) ENGINE=InnoDB COMMENT='公共异步导出任务表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_export_task_guard (
|
||||
guard_key VARCHAR(64) NOT NULL COMMENT '任务创建串行化锁键',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (guard_key)
|
||||
) ENGINE=InnoDB COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限';
|
||||
|
||||
INSERT IGNORE INTO file_export_task_guard (guard_key) VALUES ('GLOBAL_CREATE');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_export_outbox (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'Outbox记录ID',
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '投递意图唯一标识',
|
||||
task_id BIGINT UNSIGNED NOT NULL COMMENT '关联导出任务ID',
|
||||
dispatch_version INT NOT NULL COMMENT '任务派发版本',
|
||||
topic VARCHAR(128) NOT NULL COMMENT '目标Topic',
|
||||
tag VARCHAR(64) NOT NULL COMMENT '消息Tag',
|
||||
message_key VARCHAR(64) NOT NULL COMMENT '消息Key',
|
||||
payload_json JSON NOT NULL COMMENT '命令消息体',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CLAIMED/SENT/QUARANTINED',
|
||||
attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数',
|
||||
next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间',
|
||||
lease_owner VARCHAR(128) NULL COMMENT '领取实例标识',
|
||||
lease_until DATETIME(3) NULL COMMENT '领取租约截止时间',
|
||||
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后发送错误',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_file_export_outbox_event (event_id),
|
||||
UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version),
|
||||
KEY idx_file_export_outbox_publish (status, next_retry_at, id),
|
||||
KEY idx_file_export_outbox_task (task_id, dispatch_version, status, sent_at)
|
||||
) ENGINE=InnoDB COMMENT='导出命令事务Outbox表';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportProviderClientTest {
|
||||
@Test void routesTheRequestToTheAllowlistedServiceSelectedByTheDefinition() {
|
||||
IamExportProviderClient iam=mock(IamExportProviderClient.class);
|
||||
OrderExportProviderClient order=mock(OrderExportProviderClient.class);
|
||||
WarehouseExportProviderClient warehouse=mock(WarehouseExportProviderClient.class);
|
||||
FirstlegExportProviderClient firstleg=mock(FirstlegExportProviderClient.class);
|
||||
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
|
||||
when(order.batch(anyString(),anyString(),any())).thenReturn(response);
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("order-service");
|
||||
ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500);
|
||||
|
||||
var actual=new ExportProviderClient(iam,order,warehouse,firstleg,new SimpleMeterRegistry())
|
||||
.batch("token","ORDER_LIST",definition,request);
|
||||
|
||||
assertSame(response,actual);
|
||||
verify(order).batch("token","ORDER_LIST",request);
|
||||
verifyNoInteractions(iam,warehouse,firstleg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
import feign.codec.Decoder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ExportProviderFeignConfigurationTest {
|
||||
@Test void rejectsAnOversizedProviderResponseBeforeJsonDeserialization() {
|
||||
ExportProviderFeignConfiguration configuration=new ExportProviderFeignConfiguration();
|
||||
Decoder decoder=configuration.exportProviderDecoder(HttpMessageConverters::new);
|
||||
byte[] body=new byte[ExportProviderFeignConfiguration.MAX_RESPONSE_BYTES+1];
|
||||
Response response=Response.builder().status(200).reason("OK")
|
||||
.request(Request.create(Request.HttpMethod.POST,"http://iam-service/internal/exports/v1/CUSTOMER_LIST/batches",Map.of(),null, StandardCharsets.UTF_8,null))
|
||||
.body(body).build();
|
||||
|
||||
assertThrows(ExportProviderResponseTooLargeException.class,() -> decoder.decode(response,Object.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ExportDefinitionRegistryTest {
|
||||
@Test void acceptsTheControlledCustomerProvider() {
|
||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches")));
|
||||
assertDoesNotThrow(registry::validate);
|
||||
}
|
||||
@Test void rejectsSsrfAndPathTypeMismatch() {
|
||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
|
||||
}
|
||||
@Test void rejectedRefreshKeepsTheLastValidDefinitions() {
|
||||
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
|
||||
var valid=definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches");
|
||||
registry.setDefinitions(Map.of("CUSTOMER_LIST", valid));
|
||||
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/CUSTOMER_LIST/batches"))));
|
||||
assertDoesNotThrow(() -> registry.require("CUSTOMER_LIST"));
|
||||
org.junit.jupiter.api.Assertions.assertSame(valid, registry.require("CUSTOMER_LIST"));
|
||||
}
|
||||
private ExportDefinitionRegistry.Definition definition(String service,String path){
|
||||
var d=new ExportDefinitionRegistry.Definition();d.setProviderService(service);d.setProviderPath(path);d.setRequiredPermission("platform:customer:export");d.setAppScope("PLATFORM");d.setMaxRows(200000);d.setMaxAttempts(3);d.setBatchSize(500);return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ExportInfrastructureContractTest {
|
||||
@Test void deploymentPinsConcurrencyFourAndStartsAllSchedulersDisabled() throws Exception {
|
||||
String compose=read("..","deploy","docker-compose.yml");
|
||||
assertTrue(compose.contains("FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}"));
|
||||
assertTrue(compose.contains("-Xms1g -Xmx2g"));
|
||||
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?"));
|
||||
assertTrue(!compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-}"));
|
||||
}
|
||||
|
||||
@Test void rocketMqBootstrapCreatesBothGroupsAndAReadableWorkerDlq() throws Exception {
|
||||
String compose=read("..","deploy","docker-compose.rocketmq.yml");
|
||||
assertTrue(compose.contains("baiyue-file-export-command-v1\" -r 4 -w 4"));
|
||||
assertTrue(compose.contains("baiyue-file-export-worker-v1\""));
|
||||
assertTrue(compose.contains("baiyue-file-export-dlq-handler-v1\""));
|
||||
assertTrue(compose.contains("%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1\" -r 1 -w 1 -p 6"));
|
||||
assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX?"));
|
||||
assertTrue(compose.contains("sh -eu -c 'until sh mqadmin clusterList"));
|
||||
assertTrue(compose.contains("| grep -q \"BaiyueCluster\""));
|
||||
assertTrue(compose.contains("restart: on-failure"));
|
||||
String environment=read("..","deploy","env",".env.infra-test.example");
|
||||
assertTrue(environment.contains("ROCKETMQ_BROKER_IP=rocketmq-broker"));
|
||||
assertTrue(environment.contains("MQ_TOPIC_PREFIX=test-"));
|
||||
assertTrue(read("..","deploy","rocketmq","broker.conf.template").contains("autoCreateSubscriptionGroup=false"));
|
||||
}
|
||||
|
||||
@Test void brokerRendererRejectsUnreachableAndUnexpandedAddresses() throws Exception {
|
||||
String script=read("..","deploy","rocketmq","render-broker-config.sh");
|
||||
assertTrue(script.contains("localhost|127.0.0.1"));
|
||||
assertTrue(script.contains("*'${'*"));
|
||||
assertTrue(script.contains("broker.conf still contains an unresolved placeholder"));
|
||||
}
|
||||
|
||||
@Test void databaseBaselineContainsTaskOutboxAndPreseededCreationGuard() throws Exception {
|
||||
String sql=read("src","main","resources","db","file.sql");
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_task ("));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_outbox ("));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS file_export_task_guard ("));
|
||||
assertTrue(sql.contains("VALUES ('GLOBAL_CREATE')"));
|
||||
}
|
||||
|
||||
private String read(String first,String... more) throws Exception {
|
||||
return Files.readString(Path.of(first,more), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,44 @@ class QualificationFileContractTest {
|
||||
assertTrue(requestCaptor.getValue().replace());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
var controller = merchantController(storage, iamClient);
|
||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(merchantUser(), file, null, null, null, false));
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(merchantUser(), file, "UNKNOWN", null, null, false));
|
||||
|
||||
verify(storage, never()).store(any(Long.class), any());
|
||||
verify(iamClient, never()).submit(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRequiredQualificationTypeIsAcceptedAndForwarded() {
|
||||
for (String qualificationType : Set.of(
|
||||
"BUSINESS_LICENSE", "LEGAL_PERSON_ID",
|
||||
"FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
var controller = merchantController(storage, iamClient);
|
||||
var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
when(storage.store(10001L, file)).thenReturn(storedFile());
|
||||
when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", null)));
|
||||
|
||||
controller.upload(merchantUser(), file, qualificationType, null, null, false);
|
||||
|
||||
var requestCaptor = org.mockito.ArgumentCaptor.forClass(
|
||||
IamQualificationClient.QualificationRequest.class);
|
||||
verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
|
||||
assertEquals(qualificationType, requestCaptor.getValue().qualificationType());
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformQualificationFileController platformController(
|
||||
LocalQualificationStorage storage, IamQualificationClient iamClient) {
|
||||
var controller = new PlatformQualificationFileController();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.file.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ExportMapperContractTest {
|
||||
@Test void finalSuccessCommitIsGuardedByVersionLeaseAndDatabaseDeadline() throws Exception {
|
||||
String sql=update(FileExportTaskMapper.class,"succeed");
|
||||
assertTrue(sql.contains("dispatch_version=#{dispatchVersion}"));
|
||||
assertTrue(sql.contains("lease_owner=#{owner}"));
|
||||
assertTrue(sql.contains("execution_deadline_at>CURRENT_TIMESTAMP(3)"));
|
||||
}
|
||||
|
||||
@Test void sentRedispatchKeepsTheSameVersionAndExcludesQuarantinedRows() throws Exception {
|
||||
String sql=update(FileExportOutboxMapper.class,"redispatchSent");
|
||||
assertTrue(sql.contains("o.status='SENT'"));
|
||||
assertTrue(sql.contains("t.dispatch_version=o.dispatch_version"));
|
||||
assertFalse(sql.contains("dispatch_version=dispatch_version+1"));
|
||||
assertFalse(sql.contains("QUARANTINED"));
|
||||
}
|
||||
|
||||
@Test void missingOutboxDetectionUsesDatabaseTimeAndLetsAnyExistingStatusBlockDuplication() throws Exception {
|
||||
String sql=select(FileExportTaskMapper.class,"missingOutbox");
|
||||
assertTrue(sql.contains("CURRENT_TIMESTAMP(3)"));
|
||||
assertTrue(sql.contains("NOT EXISTS"));
|
||||
assertTrue(sql.contains("o.dispatch_version=file_export_task.dispatch_version"));
|
||||
assertFalse(sql.contains("o.status"));
|
||||
}
|
||||
|
||||
@Test void retentionCleanupCanOnlyDeleteOldSentRows() throws Exception {
|
||||
Method target=java.util.Arrays.stream(FileExportOutboxMapper.class.getDeclaredMethods()).filter(candidate -> candidate.getName().equals("deleteRetainedSent")).findFirst().orElseThrow();
|
||||
String sql=String.join(" ",target.getAnnotation(Delete.class).value()).replaceAll("\\s+"," ");
|
||||
assertTrue(sql.contains("status='SENT'"));
|
||||
assertTrue(sql.contains("CURRENT_TIMESTAMP(3)"));
|
||||
assertFalse(sql.contains("PENDING"));
|
||||
assertFalse(sql.contains("CLAIMED"));
|
||||
}
|
||||
|
||||
private String update(Class<?> type,String method) throws Exception {
|
||||
Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow();
|
||||
return String.join(" ",target.getAnnotation(Update.class).value()).replaceAll("\\s+"," ");
|
||||
}
|
||||
private String select(Class<?> type,String method) throws Exception {
|
||||
Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow();
|
||||
return String.join(" ",target.getAnnotation(Select.class).value()).replaceAll("\\s+"," ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.service.ExportTaskExecutor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class ExportCommandConsumerTest {
|
||||
@Test void appliesConfiguredConcurrencyAndDispatchesAValidCommand() {
|
||||
ExportTaskExecutor executor=mock(ExportTaskExecutor.class);
|
||||
ExportCommandConsumer consumer=new ExportCommandConsumer(new ObjectMapper(),executor,"file-1",4,90,2);
|
||||
DefaultMQPushConsumer pushConsumer=new DefaultMQPushConsumer();
|
||||
consumer.prepareStart(pushConsumer);
|
||||
assertEquals(4,pushConsumer.getConsumeThreadMin());
|
||||
assertEquals(4,pushConsumer.getConsumeThreadMax());
|
||||
assertEquals(90,pushConsumer.getConsumeTimeout());
|
||||
assertEquals(2,pushConsumer.getMaxReconsumeTimes());
|
||||
|
||||
MessageExt message=new MessageExt();
|
||||
message.setBody("{\"schemaVersion\":1,\"eventId\":\"event-1\",\"taskId\":\"9\",\"exportType\":\"CUSTOMER_LIST\",\"dispatchVersion\":3}".getBytes(StandardCharsets.UTF_8));
|
||||
consumer.onMessage(message);
|
||||
verify(executor).execute(9L,3,"file-1");
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedMessagesSoRocketMqCanRedeliverThem() {
|
||||
ExportCommandConsumer consumer=new ExportCommandConsumer(new ObjectMapper(),mock(ExportTaskExecutor.class),"file-1",4,90,2);
|
||||
MessageExt message=new MessageExt();message.setBody("not-json".getBytes(StandardCharsets.UTF_8));
|
||||
assertThrows(IllegalArgumentException.class,() -> consumer.onMessage(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.service.ExportStateService;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportDlqHandlerTest {
|
||||
@Test void databaseFailuresCannotFallIntoAnUnconsumedSecondaryDlq() {
|
||||
RocketMQMessageListener listener=ExportDlqHandler.class.getAnnotation(RocketMQMessageListener.class);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(Integer.MAX_VALUE,listener.maxReconsumeTimes());
|
||||
}
|
||||
@Test void quarantinesUsingUserPropertiesWithoutParsingTheBody() {
|
||||
ExportStateService states=mock(ExportStateService.class); MessageExt message=new MessageExt();
|
||||
SimpleMeterRegistry meters=new SimpleMeterRegistry();
|
||||
message.putUserProperty("eventId","event-1");message.putUserProperty("taskId","9");message.putUserProperty("dispatchVersion","3");message.putUserProperty("schemaVersion","1");
|
||||
new ExportDlqHandler(states,meters).onMessage(message);
|
||||
verify(states).quarantine(eq("event-1"),eq(9L),eq(3),contains("自动隔离"));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count());
|
||||
}
|
||||
@Test void malformedPropertiesAreAcknowledgedWithoutTouchingTasks() {
|
||||
ExportStateService states=mock(ExportStateService.class); SimpleMeterRegistry meters=new SimpleMeterRegistry();
|
||||
new ExportDlqHandler(states,meters).onMessage(new MessageExt());
|
||||
verifyNoInteractions(states); org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.file.mq;
|
||||
|
||||
import com.baiyue.file.entity.FileExportOutbox;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportOutboxPublisherTest {
|
||||
@Test void publishesAllDlqLocatorFieldsAsRocketMqUserProperties() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
RocketMQTemplate rocket=mock(RocketMQTemplate.class);
|
||||
FileExportOutbox row=row();
|
||||
when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row);
|
||||
|
||||
new ExportOutboxPublisher(outbox,rocket,"file-1",50,60).publish();
|
||||
|
||||
@SuppressWarnings("rawtypes") ArgumentCaptor<Message> message=ArgumentCaptor.forClass(Message.class);
|
||||
verify(rocket).syncSend(eq("test-export:EXPORT_TASK"),message.capture(),eq(3000L));
|
||||
assertEquals("event-1",message.getValue().getHeaders().get("eventId"));
|
||||
assertEquals("9",message.getValue().getHeaders().get("taskId"));
|
||||
assertEquals("3",message.getValue().getHeaders().get("dispatchVersion"));
|
||||
assertEquals("1",message.getValue().getHeaders().get("schemaVersion"));
|
||||
verify(outbox).sent(1L,"file-1");
|
||||
}
|
||||
|
||||
@Test void returnsFailedSendsToPendingWithTheFirstBackoffInsteadOfGivingUp() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
RocketMQTemplate rocket=mock(RocketMQTemplate.class);
|
||||
FileExportOutbox row=row();row.setAttemptCount(1);
|
||||
when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row);
|
||||
doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong());
|
||||
|
||||
new ExportOutboxPublisher(outbox,rocket,"file-1",50,60).publish();
|
||||
|
||||
verify(outbox).sendFailed(1L,"file-1",5,"broker down");
|
||||
verify(outbox,never()).sent(anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void stillRetriesAfterThirtyConsecutiveBrokerFailuresWithoutADeadState() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
RocketMQTemplate rocket=mock(RocketMQTemplate.class);
|
||||
FileExportOutbox row=row();row.setAttemptCount(30);
|
||||
when(outbox.publishableIds(50)).thenReturn(List.of(1L));
|
||||
when(outbox.claim(1L,"file-1",60)).thenReturn(true);
|
||||
when(outbox.find(1L)).thenReturn(row);
|
||||
doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong());
|
||||
ExportOutboxPublisher publisher=new ExportOutboxPublisher(outbox,rocket,"file-1",50,60);
|
||||
|
||||
for(int i=0;i<30;i++) publisher.publish();
|
||||
|
||||
verify(outbox,times(30)).sendFailed(1L,"file-1",900,"broker down");
|
||||
verify(outbox,never()).sent(anyLong(),anyString());
|
||||
}
|
||||
|
||||
private FileExportOutbox row(){
|
||||
FileExportOutbox row=new FileExportOutbox();row.setId(1L);row.setEventId("event-1");row.setTaskId(9L);row.setDispatchVersion(3);
|
||||
row.setTopic("test-export");row.setTag("EXPORT_TASK");row.setMessageKey("9");row.setPayloadJson("{}");row.setAttemptCount(1);return row;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.file.client.IamExportClient;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CustomerExportTaskHandlerTest {
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Test
|
||||
void generatesARealXlsxFromIamBatchesAndReportsProgress() throws Exception {
|
||||
IamExportClient client = mock(IamExportClient.class);
|
||||
LocalExportStorage storage = new LocalExportStorage(tempDir.toString());
|
||||
storage.initialize();
|
||||
var row = new IamExportClient.CustomerExportRow(
|
||||
"101", "TEN101", "柏悦物流", "柏悦物流有限公司", "91440300MA5ABCDE12",
|
||||
"有限责任公司", null, null, "CNY", "深圳市", "张三", "13800138000",
|
||||
"ops@example.com", "baiyue", "李四", "基础版", 30, "ACTIVE", null, null);
|
||||
when(client.customers("internal-token", null, 500, "柏悦", "ACTIVE"))
|
||||
.thenReturn(ApiResponse.success(new IamExportClient.CustomerExportBatch(List.of(row), 101L, false)));
|
||||
CustomerExportTaskHandler handler = new CustomerExportTaskHandler(
|
||||
client, storage, new ObjectMapper().findAndRegisterModules(), "internal-token");
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L);
|
||||
task.setParametersJson("{\"keyword\":\"柏悦\",\"status\":\"ACTIVE\"}");
|
||||
AtomicLong progress = new AtomicLong();
|
||||
|
||||
var result = handler.export(task, progress::set);
|
||||
|
||||
assertEquals(1L, result.rows());
|
||||
assertEquals(1L, progress.get());
|
||||
assertTrue(result.fileName().endsWith(".xlsx"));
|
||||
Path workbookPath = tempDir.resolve(result.objectKey());
|
||||
assertTrue(Files.isRegularFile(workbookPath));
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(Files.newInputStream(workbookPath))) {
|
||||
assertEquals("客户ID", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("柏悦物流", workbook.getSheetAt(0).getRow(1).getCell(2).getStringCellValue());
|
||||
assertEquals("13800138000", workbook.getSheetAt(0).getRow(1).getCell(11).getStringCellValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportReconcilerTest {
|
||||
@Test void performsEveryRecoveryResponsibilityWithoutExecutingBusinessExports() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
ExportStorage storage=mock(ExportStorage.class);
|
||||
FileExportTask missing=task(1L,"PENDING"), stale=task(2L,"RUNNING"), expired=task(3L,"SUCCEEDED");expired.setObjectKey("exports/3/file.xlsx");
|
||||
when(outbox.releaseExpiredClaims()).thenReturn(2);when(outbox.redispatchSent(600,60)).thenReturn(3);
|
||||
when(tasks.missingOutbox(60)).thenReturn(List.of(missing));when(states.ensureOutbox(missing)).thenReturn(true);
|
||||
when(tasks.staleRunning()).thenReturn(List.of(stale));when(states.recoverStale(stale)).thenReturn(true);
|
||||
when(tasks.expiredArtifactsDb()).thenReturn(List.of(expired));when(storage.delete(expired.getObjectKey())).thenReturn(true);
|
||||
when(storage.cleanupOrphans(eq(Duration.ofHours(24)),any())).thenReturn(1);
|
||||
ExportReconciler reconciler=new ExportReconciler(tasks,outbox,states,storage,new SimpleMeterRegistry(),60,600,7);
|
||||
|
||||
reconciler.reconcile();
|
||||
|
||||
verify(outbox).releaseExpiredClaims();verify(outbox).redispatchSent(600,60);
|
||||
verify(states).ensureOutbox(missing);verify(states).recoverStale(stale);verify(tasks).markExpired(3L);
|
||||
verify(storage).cleanupOrphans(eq(Duration.ofHours(24)),any());
|
||||
}
|
||||
|
||||
@Test void deletesOnlyTheConfiguredDailyBatchOfRetainedSentOutboxRows() {
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
ExportReconciler reconciler=new ExportReconciler(mock(ExportTaskRepository.class),outbox,mock(ExportStateService.class),mock(ExportStorage.class),new SimpleMeterRegistry(),60,600,7);
|
||||
reconciler.cleanupRetainedOutbox();
|
||||
verify(outbox).deleteRetainedSent(7,5000);
|
||||
}
|
||||
|
||||
private FileExportTask task(long id,String status){FileExportTask task=new FileExportTask();task.setId(id);task.setStatus(status);return task;}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.repository.ExportOutboxRepository;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportStateServiceTest {
|
||||
@Test void creationLimitsAndDuplicateDetectionRunUnderTheDatabaseGuard() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
ExportCommandFactory commands=mock(ExportCommandFactory.class);
|
||||
FileExportTask task=task(9L,1,"PENDING");
|
||||
task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setExportType("CUSTOMER_LIST");task.setParametersJson("{}");
|
||||
when(commands.create(task,1)).thenReturn(mock(com.baiyue.file.entity.FileExportOutbox.class));
|
||||
when(outbox.insert(any())).thenReturn(true);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,commands,mock(ExportMetrics.class));
|
||||
|
||||
ExportStateService.CreationResult result=service.createConstrained(task);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(result.created());
|
||||
InOrder order=inOrder(tasks,outbox);
|
||||
order.verify(tasks).lockCreationGuard();
|
||||
order.verify(tasks).recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}");
|
||||
order.verify(tasks).activeForUser(0L,"PLATFORM",7L);
|
||||
order.verify(tasks).activeForType("CUSTOMER_LIST");
|
||||
order.verify(tasks).insert(task);
|
||||
order.verify(tasks).initializeDispatchTime(9L);
|
||||
order.verify(outbox).insert(any());
|
||||
}
|
||||
|
||||
@Test void duplicateCreationReturnsTheExistingTaskWithoutWritingAnotherOutbox() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
FileExportTask request=task(9L,1,"PENDING"), existing=task(8L,1,"PENDING");
|
||||
request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}");
|
||||
when(tasks.recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}")).thenReturn(existing);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
ExportStateService.CreationResult result=service.createConstrained(request);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(result.created());
|
||||
org.junit.jupiter.api.Assertions.assertSame(existing,result.task());
|
||||
verify(tasks).lockCreationGuard();
|
||||
verify(tasks,never()).insert(any());
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
@Test void activeTaskLimitsAreEnforcedAfterTakingTheCreationGuard() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
FileExportTask request=task(9L,1,"PENDING");
|
||||
request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}");
|
||||
when(tasks.activeForUser(0L,"PLATFORM",7L)).thenReturn(3L);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,() -> service.createConstrained(request));
|
||||
|
||||
InOrder order=inOrder(tasks);
|
||||
order.verify(tasks).lockCreationGuard();
|
||||
order.verify(tasks).recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}");
|
||||
order.verify(tasks).activeForUser(0L,"PLATFORM",7L);
|
||||
verify(tasks,never()).insert(any());
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
@Test void quarantineRequiresOutboxAndTaskToChangeAtomically() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING"));
|
||||
when(outbox.quarantine("event-1",9L,3,"broken")).thenReturn(true);
|
||||
when(tasks.failQuarantined(9L,3,"broken")).thenReturn(false);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertThrows(IllegalStateException.class,() -> service.quarantine("event-1",9L,3,"broken"));
|
||||
verify(tasks).failQuarantined(9L,3,"broken");
|
||||
}
|
||||
|
||||
@Test void oldDlqMessageThatMatchesNoOutboxDoesNotTouchTheCurrentTask() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING"));
|
||||
when(outbox.quarantine(anyString(),anyLong(),anyInt(),anyString())).thenReturn(false);
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertDoesNotThrow(() -> service.quarantine("old-event",9L,2,"broken"));
|
||||
verify(tasks).find(9L);
|
||||
verifyNoMoreInteractions(tasks);
|
||||
}
|
||||
|
||||
@Test void matchingDlqMessageDoesNotDamageAnAlreadyRunningTask() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportOutboxRepository outbox=mock(ExportOutboxRepository.class);
|
||||
when(tasks.find(9L)).thenReturn(task(9L,3,"RUNNING"));
|
||||
ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class));
|
||||
|
||||
assertDoesNotThrow(() -> service.quarantine("event-1",9L,3,"broken"));
|
||||
verifyNoInteractions(outbox);
|
||||
verify(tasks,never()).failQuarantined(anyLong(),anyInt(),anyString());
|
||||
}
|
||||
|
||||
private FileExportTask task(long id,int version,String status){
|
||||
FileExportTask task=new FileExportTask();task.setId(id);task.setDispatchVersion(version);task.setStatus(status);return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ExportTaskExecutorTest {
|
||||
@Test void aCommandThatCannotClaimTheTaskIsAcknowledgedWithoutExecutingOrChangingState() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(false);
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,mock(ExportStorage.class),new SimpleMeterRegistry(),300,60,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verifyNoInteractions(definitions,runner,states);
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void watchdogRenewsTheLeaseWhileTheRunnerIsStillWriting() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
FileExportTask running=task(1,"file-1");
|
||||
when(tasks.claim(9L,1,"file-1",5,30)).thenReturn(true);when(tasks.find(9L)).thenReturn(running);
|
||||
when(tasks.heartbeat(9L,1,"file-1",5)).thenReturn(true);when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt())).thenReturn(true);
|
||||
when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition());
|
||||
when(runner.run(eq(running),any(),any(AtomicBoolean.class),any())).thenAnswer(invocation -> {
|
||||
Thread.sleep(1200);
|
||||
return new GenericXlsxExportRunner.Result("file.xlsx","exports/9/test.xlsx","application/xlsx",100,1);
|
||||
});
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,mock(ExportStorage.class),new SimpleMeterRegistry(),5,1,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verify(tasks,atLeastOnce()).heartbeat(9L,1,"file-1",5);
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void aLateFailureFromAnOldExecutionCannotRetryOrFailANewerVersionOwnedByTheSameInstance() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
ExportStorage storage=mock(ExportStorage.class);
|
||||
when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true);
|
||||
FileExportTask first=task(1,"file-1"), newer=task(2,"file-1");
|
||||
when(tasks.find(9L)).thenReturn(first,newer);
|
||||
when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition());
|
||||
when(runner.run(eq(first),any(),any(),any())).thenThrow(new RuntimeException("late failure"));
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,storage,new SimpleMeterRegistry(),300,60,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verifyNoInteractions(states);
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void deletesTheGeneratedFileWhenTheFinalDatabaseCommitThrows() {
|
||||
ExportTaskRepository tasks=mock(ExportTaskRepository.class);
|
||||
ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class);
|
||||
GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class);
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
ExportStorage storage=mock(ExportStorage.class);
|
||||
FileExportTask running=task(1,"file-1");
|
||||
when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true);
|
||||
when(tasks.find(9L)).thenReturn(running);
|
||||
when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition());
|
||||
when(runner.run(eq(running),any(),any(),any())).thenReturn(
|
||||
new GenericXlsxExportRunner.Result("file.xlsx","exports/9/result.xlsx","application/xlsx",100,1));
|
||||
when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt()))
|
||||
.thenThrow(new RuntimeException("database unavailable"));
|
||||
when(states.retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),anyString())).thenReturn(true);
|
||||
ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,storage,new SimpleMeterRegistry(),300,60,30,7);
|
||||
|
||||
executor.execute(9L,1,"file-1");
|
||||
|
||||
verify(storage).delete("exports/9/result.xlsx");
|
||||
verify(states).retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),eq("database unavailable"));
|
||||
executor.shutdownWatchdogs();
|
||||
}
|
||||
|
||||
@Test void retryBackoffAlwaysStaysWithinTheDocumentedTwentyPercentJitter() {
|
||||
for (int i=0;i<100;i++) {
|
||||
int first=ExportTaskExecutor.jitteredBackoffSeconds(60);
|
||||
int second=ExportTaskExecutor.jitteredBackoffSeconds(900);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(first>=48 && first<=72);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(second>=720 && second<=1080);
|
||||
}
|
||||
}
|
||||
|
||||
private FileExportTask task(int version,String owner){
|
||||
FileExportTask task=new FileExportTask();task.setId(9L);task.setExportType("CUSTOMER_LIST");task.setStatus("RUNNING");
|
||||
task.setDispatchVersion(version);task.setLeaseOwner(owner);task.setAttemptCount(1);task.setMaxAttempts(3);return task;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -25,19 +26,18 @@ class ExportTaskServiceTest {
|
||||
@Test
|
||||
void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
ExportTaskHandler handler = mock(ExportTaskHandler.class);
|
||||
when(handler.exportType()).thenReturn("CUSTOMER_LIST");
|
||||
when(handler.requiredPermission()).thenReturn("platform:customer:export");
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class),
|
||||
new ObjectMapper(), List.of(handler));
|
||||
doAnswer(invocation -> null).when(repository).insert(any());
|
||||
ExportStateService states=mock(ExportStateService.class);
|
||||
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
|
||||
new ExportStateService.CreationResult(invocation.getArgument(0), true));
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(ExportStorage.class),
|
||||
new ObjectMapper(), registry(), states, mock(ExportMetrics.class));
|
||||
|
||||
var response = service.create(user(1L, Set.of("platform:customer:export")),
|
||||
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("status", "ACTIVE")));
|
||||
|
||||
assertEquals("PENDING", response.status());
|
||||
assertEquals("CUSTOMER_LIST", response.exportType());
|
||||
verify(repository).insert(any(FileExportTask.class));
|
||||
verify(states).createConstrained(any(FileExportTask.class));
|
||||
BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of()),
|
||||
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of())));
|
||||
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
|
||||
@@ -46,10 +46,8 @@ class ExportTaskServiceTest {
|
||||
@Test
|
||||
void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
ExportTaskHandler handler = mock(ExportTaskHandler.class);
|
||||
when(handler.exportType()).thenReturn("CUSTOMER_LIST");
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class),
|
||||
new ObjectMapper(), List.of(handler));
|
||||
ExportTaskService service = new ExportTaskService(repository, mock(ExportStorage.class),
|
||||
new ObjectMapper(), registry(), mock(ExportStateService.class), mock(ExportMetrics.class));
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
|
||||
when(repository.find(9L)).thenReturn(task);
|
||||
@@ -59,7 +57,22 @@ class ExportTaskServiceTest {
|
||||
assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsParametersDeeperThanTwoLevels() {
|
||||
ExportTaskService service = new ExportTaskService(mock(ExportTaskRepository.class), mock(ExportStorage.class),
|
||||
new ObjectMapper(), registry(), mock(ExportStateService.class), mock(ExportMetrics.class));
|
||||
var request = new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表",
|
||||
Map.of("level1", Map.of("level2", List.of(Map.of("level3", "value")))));
|
||||
|
||||
BizException error=assertThrows(BizException.class,() -> service.create(user(1L,Set.of("platform:customer:export")),request));
|
||||
assertEquals(ResultCode.VALIDATION_FAILED,error.getResultCode());
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(long id, Set<String> permissions) {
|
||||
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
private ExportDefinitionRegistry registry(){
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("iam-service");definition.setProviderPath("/internal/exports/v1/CUSTOMER_LIST/batches");definition.setRequiredPermission("platform:customer:export");definition.setAppScope("PLATFORM");definition.setMaxRows(200000);definition.setMaxAttempts(3);definition.setBatchSize(500);
|
||||
ExportDefinitionRegistry registry=mock(ExportDefinitionRegistry.class);when(registry.require("CUSTOMER_LIST")).thenReturn(definition);return registry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.baiyue.file.repository.ExportTaskRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ExportTaskWorkerTest {
|
||||
@Test
|
||||
void onlyExecutesATaskAfterTheAtomicClaimSucceeds() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
ExportTaskService service = mock(ExportTaskService.class);
|
||||
LocalExportStorage storage = mock(LocalExportStorage.class);
|
||||
ExportTaskHandler handler = mock(ExportTaskHandler.class);
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L); task.setExportType("CUSTOMER_LIST");
|
||||
when(repository.firstPendingId()).thenReturn(9L);
|
||||
when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(true);
|
||||
when(repository.find(9L)).thenReturn(task);
|
||||
when(service.handler("CUSTOMER_LIST")).thenReturn(handler);
|
||||
when(handler.export(eq(task), any())).thenAnswer(invocation -> {
|
||||
ExportTaskHandler.ProgressReporter progress = invocation.getArgument(1);
|
||||
progress.update(12L);
|
||||
return new ExportTaskHandler.ExportResult("客户列表.xlsx", "exports/9/file.xlsx",
|
||||
CustomerExportTaskHandler.CONTENT_TYPE, 1024L, 12L);
|
||||
});
|
||||
when(repository.succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"),
|
||||
eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
ExportTaskWorker worker = new ExportTaskWorker(repository, service, storage, 7, 30);
|
||||
|
||||
worker.processNext();
|
||||
|
||||
verify(repository).progress(9L, 12L);
|
||||
verify(repository).succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"),
|
||||
eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L),
|
||||
any(LocalDateTime.class), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsAContendedTaskThatAnotherWorkerClaimed() {
|
||||
ExportTaskRepository repository = mock(ExportTaskRepository.class);
|
||||
when(repository.firstPendingId()).thenReturn(9L);
|
||||
when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(false);
|
||||
ExportTaskWorker worker = new ExportTaskWorker(repository, mock(ExportTaskService.class),
|
||||
mock(LocalExportStorage.class), 7, 30);
|
||||
|
||||
worker.processNext();
|
||||
|
||||
verify(repository, never()).find(9L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.*;
|
||||
import com.baiyue.file.client.ExportProviderClient;
|
||||
import com.baiyue.file.config.ExportDefinitionRegistry;
|
||||
import com.baiyue.file.entity.FileExportTask;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class GenericXlsxExportRunnerTest {
|
||||
@TempDir Path temp;
|
||||
@Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception {
|
||||
ExportProviderClient provider=mock(ExportProviderClient.class); LocalExportStorage storage=new LocalExportStorage(temp.toString());storage.initialize();
|
||||
List<ExportColumn> columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20));
|
||||
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
|
||||
var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L);
|
||||
var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L);
|
||||
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second));
|
||||
FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}");
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500);
|
||||
var result=new GenericXlsxExportRunner(provider,storage,new ObjectMapper(),"internal-token",50).run(task,definition,new AtomicBoolean(),ignored->{ });
|
||||
assertEquals(101,result.rows());
|
||||
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){
|
||||
var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue());
|
||||
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
|
||||
assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue());
|
||||
assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建");
|
||||
}
|
||||
ArgumentCaptor<ExportBatchRequest> requests=ArgumentCaptor.forClass(ExportBatchRequest.class);verify(provider,times(2)).batch(anyString(),eq("CUSTOMER_LIST"),eq(definition),requests.capture());
|
||||
assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken());
|
||||
}
|
||||
|
||||
@Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception {
|
||||
ExportProviderClient provider=mock(ExportProviderClient.class);
|
||||
LocalExportStorage storage=spy(new LocalExportStorage(temp.toString()));storage.initialize();
|
||||
var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9",
|
||||
List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)),
|
||||
List.of(Map.of("text","row")),null,false,1L);
|
||||
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}");
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500);
|
||||
doThrow(new RuntimeException("size unavailable")).when(storage).size(anyString());
|
||||
|
||||
assertThrows(ExportFailure.class,() -> new GenericXlsxExportRunner(provider,storage,new ObjectMapper(),"internal-token",50)
|
||||
.run(task,definition,new AtomicBoolean(),ignored->{ }));
|
||||
|
||||
try(var paths=Files.walk(temp.resolve("exports"))) {
|
||||
assertEquals(0,paths.filter(Files::isRegularFile).count());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,19 @@ class LocalQualificationStorageTest {
|
||||
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() {
|
||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||
storage.initialize();
|
||||
var upload = new MockMultipartFile("file", "oversized.pdf", "application/pdf",
|
||||
new byte[5 * 1024 * 1024 + 1]);
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
() -> storage.store(10001L, upload));
|
||||
|
||||
assertTrue(exception.getMessage().contains("5 MB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesTheReplacedQualificationByItsValidatedDocumentUrl() {
|
||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SizeLimitedOutputStreamTest {
|
||||
@Test void rejectsTheWriteBeforeAnyBytesBeyondTheLimitReachStorage() throws Exception {
|
||||
ByteArrayOutputStream target=new ByteArrayOutputStream();
|
||||
SizeLimitedOutputStream output=new SizeLimitedOutputStream(target,4);
|
||||
output.write(new byte[]{1,2,3,4});
|
||||
|
||||
assertThrows(SizeLimitedOutputStream.ExportSizeExceededException.class,() -> output.write(5));
|
||||
assertArrayEquals(new byte[]{1,2,3,4},target.toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,9 @@
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-stream-rocketmq</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@
|
||||
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
|
||||
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
|
||||
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。
|
||||
- 企业送审和审核通过都要求 `BUSINESS_LICENSE` 与 `LEGAL_PERSON_ID`;审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,条件更新负责并发防重。
|
||||
- 企业送审和审核通过都要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID`、`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 四类资质均为 `PENDING`;审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。
|
||||
- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。
|
||||
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
|
||||
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
|
||||
- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前租户。
|
||||
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。
|
||||
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须四类必填资质全部为 `APPROVED`。任意一类缺失、驳回、已替换或过期都必须由服务端阻止提交订单。
|
||||
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
|
||||
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
|
||||
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
|
||||
|
||||
@@ -31,6 +31,14 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe
|
||||
| GET | `/api/{app}/v1/iam/authorization/permissions` | PLATFORM/MERCHANT | Current user's active permission codes / 当前用户有效权限编码 |
|
||||
| GET/POST | `/api/{app}/v1/iam/permissions` | PLATFORM/MERCHANT | Permission tree / 权限树 |
|
||||
|
||||
## 审核通过初始化扩展
|
||||
|
||||
企业审核通过后的默认数据统一通过 `TenantApprovalInitializer` 扩展链初始化,`TenantService` 不直接依赖具体初始化事项。客户端自主注册时,注册账号已经创建 OWNER 成员并绑定 `MERCHANT_OWNER`,审核通过后继续沿用该账号;只有 Admin 平台代建且没有 OWNER 的企业,才由 `TenantAdministratorProvisioningService` 创建管理员账号。后续规则、费用方案或其他默认角色应各自新增初始化器,并通过 `@Order` 声明依赖顺序。
|
||||
|
||||
初始化器及其补偿必须幂等。任一初始化器失败时,协调器按完成顺序的逆序执行补偿;IAM 本地事务最终回滚时也会触发同一补偿链。初始化器名称必须非空且全局唯一,初始密码等敏感结果只能通过审核响应一次性返回,不得持久化到 IAM 或写入日志。
|
||||
|
||||
管理员初始化会验证既有 OWNER 成员、IAM 用户及 Auth 账号均处于可用状态且租户、成员和应用范围一致。历史数据只有 OWNER/IAM 身份而缺少 Auth 账号时,复用原身份补建认证账号;停用或范围冲突的数据会阻止审核通过,避免激活后没有可登录管理员。部署前必须执行 `phase1-onboarding.sql`;测试环境统一执行 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql`,确保全局 `MERCHANT_OWNER` 模板及权限完整。
|
||||
|
||||
OpenAPI is synchronized in `openapi/iam-v1.yaml`.
|
||||
|
||||
## 商户成员角色字段级授权
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.iam.service.CustomerListExportProviderService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Internal provider endpoint consumed only by File Service. */
|
||||
@RestController
|
||||
@RequestMapping("/internal/exports/v1/CUSTOMER_LIST")
|
||||
public class CustomerListExportProviderController {
|
||||
private final CustomerListExportProviderService service;
|
||||
private final byte[] internalToken;
|
||||
|
||||
public CustomerListExportProviderController(
|
||||
CustomerListExportProviderService service,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.service = service;
|
||||
this.internalToken = internalToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping("/batches")
|
||||
public ApiResponse<ExportBatchResponse> batch(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody ExportBatchRequest request) {
|
||||
verifyToken(token);
|
||||
return ApiResponse.success(service.exportBatch(request));
|
||||
}
|
||||
|
||||
private void verifyToken(String token) {
|
||||
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(internalToken, supplied)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,15 @@ public class InternalTenantController {
|
||||
return ApiResponse.success(onboardingService.register(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
||||
public ApiResponse<Void> compensateRegistration(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long authUserId) {
|
||||
verify(token);
|
||||
onboardingService.compensateRegistration(authUserId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/qualifications")
|
||||
public ApiResponse<QualificationSubmissionResponse> submitQualification(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
@@ -80,7 +81,7 @@ public class PlatformTenantController {
|
||||
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.register(request));
|
||||
return ApiResponse.success(service.register(request, user.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/tenants/{tenantId:\\d+}")
|
||||
@@ -126,18 +127,17 @@ public class PlatformTenantController {
|
||||
|
||||
@PostMapping("/reviews/{id}/approve")
|
||||
@PreAuthorize("hasAuthority('platform:customer:approve')")
|
||||
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。")
|
||||
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve and activate the tenant. Client registrations keep their registration account as owner; only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活租户;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动开户并返回一次性凭据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成")
|
||||
})
|
||||
public ApiResponse<Void> approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
|
||||
public ApiResponse<ApprovalResponse> approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
|
||||
@RequestBody ReviewRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
service.review(id, user.userId(), true, request.comment());
|
||||
return ApiResponse.success(null);
|
||||
return ApiResponse.success(service.review(id, user.userId(), true, request.comment()));
|
||||
}
|
||||
|
||||
@PostMapping("/reviews/{id}/reject")
|
||||
|
||||
@@ -25,6 +25,7 @@ public final class TenantDtos {
|
||||
public record RegisterRequest(
|
||||
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
|
||||
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
|
||||
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@Schema(description = "18-character unified social credit code / 18 位统一社会信用代码", example = "91440300MA5EXAMPLE") @NotBlank @Pattern(regexp = "[0-9A-Z]{18}") String unifiedSocialCreditCode,
|
||||
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone,
|
||||
@@ -45,6 +46,7 @@ public final class TenantDtos {
|
||||
public record UpdateProfileRequest(
|
||||
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
|
||||
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
|
||||
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
|
||||
@@ -55,6 +57,7 @@ public final class TenantDtos {
|
||||
public record UpdateTenantRequest(
|
||||
@NotBlank String tenantName,
|
||||
@NotBlank String legalName,
|
||||
@NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@NotBlank String contactName,
|
||||
@NotBlank String contactPhone,
|
||||
@Email String contactEmail,
|
||||
@@ -80,9 +83,17 @@ public final class TenantDtos {
|
||||
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据")
|
||||
public record ApprovalResponse(
|
||||
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
|
||||
@Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated,
|
||||
@Schema(description = "Generated login username; null when an owner already exists / 自动生成的登录账号,已有所有者时为空", nullable = true) String administratorUsername,
|
||||
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
|
||||
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
|
||||
String legalName, String unifiedSocialCreditCode, String contactName,
|
||||
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
|
||||
String contactPhone, String contactEmail, String contactWechat,
|
||||
String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType,
|
||||
LocalDate establishedDate, BigDecimal registeredCapital,
|
||||
|
||||
@@ -23,6 +23,7 @@ public class IamTenant extends BaseEntity {
|
||||
private String tenantType;
|
||||
private String status;
|
||||
private String legalName;
|
||||
private String legalRepresentative;
|
||||
private String unifiedSocialCreditCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.mapper.IamTenantMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Read-only customer queries used by the generic export provider. */
|
||||
@Repository
|
||||
public class CustomerListExportProviderRepository {
|
||||
private final IamTenantMapper tenantMapper;
|
||||
|
||||
public CustomerListExportProviderRepository(IamTenantMapper tenantMapper) {
|
||||
this.tenantMapper = tenantMapper;
|
||||
}
|
||||
|
||||
public long maximumId(String keyword, String status) {
|
||||
LambdaQueryWrapper<IamTenant> wrapper = filters(keyword, status)
|
||||
.select(IamTenant::getId)
|
||||
.orderByDesc(IamTenant::getId)
|
||||
.last("LIMIT 1");
|
||||
IamTenant tenant = tenantMapper.selectOne(wrapper);
|
||||
return tenant == null || tenant.getId() == null ? 0L : tenant.getId();
|
||||
}
|
||||
|
||||
public long estimateRows(long maximumId, String keyword, String status) {
|
||||
if (maximumId <= 0) return 0L;
|
||||
return tenantMapper.selectCount(filters(keyword, status).le(IamTenant::getId, maximumId));
|
||||
}
|
||||
|
||||
public List<IamTenant> findBatch(Long cursor, long maximumId, int fetchSize,
|
||||
String keyword, String status) {
|
||||
if (maximumId <= 0) return List.of();
|
||||
return tenantMapper.selectList(filters(keyword, status)
|
||||
.gt(cursor != null, IamTenant::getId, cursor)
|
||||
.le(IamTenant::getId, maximumId)
|
||||
.orderByAsc(IamTenant::getId)
|
||||
.last("LIMIT " + fetchSize));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<IamTenant> filters(String keyword, String status) {
|
||||
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(StringUtils.hasText(status), IamTenant::getStatus, status);
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
wrapper.and(condition -> condition
|
||||
.like(IamTenant::getTenantCode, keyword)
|
||||
.or().like(IamTenant::getTenantName, keyword)
|
||||
.or().like(IamTenant::getLegalName, keyword)
|
||||
.or().like(IamTenant::getUnifiedSocialCreditCode, keyword)
|
||||
.or().like(IamTenant::getContactName, keyword)
|
||||
.or().like(IamTenant::getContactPhone, keyword)
|
||||
.or().like(IamTenant::getSalesOwnerName, keyword));
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ public class MemberRepository {
|
||||
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getId, id).last("LIMIT 1"));
|
||||
}
|
||||
public IamMembership findOwnerByTenant(long tenantId) {
|
||||
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, tenantId)
|
||||
.eq(IamMembership::getMemberType, "OWNER")
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
public IamMembership findByTenantAndAuthUser(long tenantId, Long authUserId) {
|
||||
IamUser user = findUserByAuthId(authUserId);
|
||||
if (user == null) return null;
|
||||
|
||||
@@ -30,6 +30,12 @@ public class TenantRepository {
|
||||
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByCreditCodeForOtherTenant(String creditCode, long tenantId) {
|
||||
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)
|
||||
.ne(IamTenant::getId, tenantId)) > 0;
|
||||
}
|
||||
|
||||
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
|
||||
/**
|
||||
* Serializes the draft qualification workflow for one enterprise. The file service and the
|
||||
@@ -47,6 +53,11 @@ public class TenantRepository {
|
||||
.orderByAsc(IamTenant::getTenantName));
|
||||
}
|
||||
public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); }
|
||||
public IamTenantReview findReviewForUpdate(Long reviewId) {
|
||||
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
|
||||
.eq(IamTenantReview::getId, reviewId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
public IamTenantReview findPendingReviewByTenant(Long tenantId) {
|
||||
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
|
||||
.eq(IamTenantReview::getTenantId, tenantId)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.repository.CustomerListExportProviderRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Implements the generic {@code CUSTOMER_LIST} export contract without writing export files. */
|
||||
@Service
|
||||
public class CustomerListExportProviderService {
|
||||
static final int SCHEMA_VERSION = 1;
|
||||
private static final int MAX_BATCH_SIZE = 500;
|
||||
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
|
||||
private static final Set<String> STATUSES = Set.of(
|
||||
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED");
|
||||
private static final Set<String> PARAMETER_KEYS = Set.of("keyword", "status");
|
||||
private static final List<ExportColumn> COLUMNS = List.of(
|
||||
column("id", "客户ID", ExportColumnType.STRING, 20),
|
||||
column("tenantCode", "客户编码", ExportColumnType.STRING, 18),
|
||||
column("tenantName", "公司名称", ExportColumnType.STRING, 28),
|
||||
column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28),
|
||||
column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24),
|
||||
column("enterpriseType", "企业类型", ExportColumnType.STRING, 16),
|
||||
column("establishedDate", "成立日期", ExportColumnType.DATE, 14),
|
||||
column("registeredCapital", "注册资本", ExportColumnType.DECIMAL, 16),
|
||||
column("registeredCapitalCurrency", "币种", ExportColumnType.STRING, 10),
|
||||
column("registeredAddress", "注册地址", ExportColumnType.STRING, 32),
|
||||
column("contactName", "联系人", ExportColumnType.STRING, 16),
|
||||
column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
|
||||
column("contactEmail", "联系邮箱", ExportColumnType.STRING, 24),
|
||||
column("contactWechat", "联系微信", ExportColumnType.STRING, 18),
|
||||
column("salesOwnerName", "销售人员", ExportColumnType.STRING, 16),
|
||||
column("servicePackage", "客户套餐", ExportColumnType.STRING, 16),
|
||||
column("paymentTermDays", "账期(天)", ExportColumnType.INTEGER, 12),
|
||||
column("status", "状态", ExportColumnType.STRING, 14),
|
||||
column("createdAt", "入驻时间", ExportColumnType.DATETIME, 21),
|
||||
column("approvedAt", "审批时间", ExportColumnType.DATETIME, 21));
|
||||
private static final String SCHEMA_HASH = schemaHash(COLUMNS);
|
||||
|
||||
private final CustomerListExportProviderRepository repository;
|
||||
private final byte[] snapshotSigningKey;
|
||||
|
||||
public CustomerListExportProviderService(
|
||||
CustomerListExportProviderRepository repository,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
|
||||
this.repository = repository;
|
||||
this.snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public ExportBatchResponse exportBatch(ExportBatchRequest request) {
|
||||
ValidatedRequest validated = validate(request);
|
||||
boolean firstBatch = validated.snapshotToken() == null;
|
||||
long maximumId = firstBatch
|
||||
? repository.maximumId(validated.keyword(), validated.status())
|
||||
: verifySnapshot(validated);
|
||||
String snapshotToken = firstBatch ? signSnapshot(validated, maximumId) : validated.snapshotToken();
|
||||
long totalRows = firstBatch
|
||||
? repository.estimateRows(maximumId, validated.keyword(), validated.status())
|
||||
: 0L;
|
||||
|
||||
List<IamTenant> source = repository.findBatch(validated.cursor(), maximumId,
|
||||
validated.limit() + 1, validated.keyword(), validated.status());
|
||||
boolean hasMore = source.size() > validated.limit();
|
||||
List<IamTenant> page = hasMore ? source.subList(0, validated.limit()) : source;
|
||||
String nextCursor = page.isEmpty()
|
||||
? validated.cursorText()
|
||||
: String.valueOf(page.get(page.size() - 1).getId());
|
||||
|
||||
return new ExportBatchResponse(SCHEMA_VERSION, SCHEMA_HASH, snapshotToken,
|
||||
firstBatch ? COLUMNS : List.of(), page.stream().map(this::row).toList(),
|
||||
nextCursor, hasMore, firstBatch ? totalRows : null);
|
||||
}
|
||||
|
||||
private ValidatedRequest validate(ExportBatchRequest request) {
|
||||
if (request == null) throw invalid("Export batch request is required");
|
||||
requirePositiveId(request.taskId(), "taskId");
|
||||
if (!"0".equals(request.tenantId())) {
|
||||
throw invalid("CUSTOMER_LIST only supports platform tenant 0");
|
||||
}
|
||||
if (!"PLATFORM".equals(request.appCode())) {
|
||||
throw invalid("CUSTOMER_LIST only supports PLATFORM appCode");
|
||||
}
|
||||
requirePositiveId(request.requestedBy(), "requestedBy");
|
||||
if (request.dispatchVersion() <= 0) throw invalid("dispatchVersion must be positive");
|
||||
if (request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) {
|
||||
throw invalid("limit must be between 1 and 500");
|
||||
}
|
||||
|
||||
Map<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
|
||||
if (!PARAMETER_KEYS.containsAll(parameters.keySet())) {
|
||||
throw invalid("CUSTOMER_LIST contains unsupported parameters");
|
||||
}
|
||||
String keyword = optionalString(parameters.get("keyword"), "keyword");
|
||||
if (keyword != null && keyword.length() > 128) throw invalid("keyword must not exceed 128 characters");
|
||||
String status = optionalString(parameters.get("status"), "status");
|
||||
if (status != null && !STATUSES.contains(status)) throw invalid("CUSTOMER_LIST status is invalid");
|
||||
|
||||
String snapshotToken = normalize(request.snapshotToken());
|
||||
if (snapshotToken != null && snapshotToken.length() > MAX_SNAPSHOT_TOKEN_LENGTH) {
|
||||
throw invalid("snapshotToken must not exceed 512 characters");
|
||||
}
|
||||
Long cursor = parseCursor(request.cursor());
|
||||
if (snapshotToken == null && cursor != null) {
|
||||
throw invalid("cursor requires a snapshotToken");
|
||||
}
|
||||
return new ValidatedRequest(request, keyword, status, cursor, normalize(request.cursor()), snapshotToken);
|
||||
}
|
||||
|
||||
private long verifySnapshot(ValidatedRequest request) {
|
||||
String[] parts = request.snapshotToken().split("\\.", -1);
|
||||
if (parts.length != 3 || !"v1".equals(parts[0])) throw invalid("snapshotToken is invalid");
|
||||
long maximumId;
|
||||
byte[] suppliedSignature;
|
||||
try {
|
||||
maximumId = Long.parseLong(parts[1]);
|
||||
suppliedSignature = Base64.getUrlDecoder().decode(parts[2]);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid("snapshotToken is invalid");
|
||||
}
|
||||
if (maximumId < 0) throw invalid("snapshotToken is invalid");
|
||||
byte[] expectedSignature = hmac(snapshotSubject(request, maximumId));
|
||||
if (!MessageDigest.isEqual(expectedSignature, suppliedSignature)) {
|
||||
throw invalid("snapshotToken does not match this export task");
|
||||
}
|
||||
if (request.cursor() != null && request.cursor() > maximumId) {
|
||||
throw invalid("cursor exceeds the export snapshot");
|
||||
}
|
||||
return maximumId;
|
||||
}
|
||||
|
||||
private String signSnapshot(ValidatedRequest request, long maximumId) {
|
||||
String signature = Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(hmac(snapshotSubject(request, maximumId)));
|
||||
return "v1." + maximumId + "." + signature;
|
||||
}
|
||||
|
||||
private String snapshotSubject(ValidatedRequest request, long maximumId) {
|
||||
ExportBatchRequest source = request.source();
|
||||
return String.join("\n", "CUSTOMER_LIST", source.taskId(), source.tenantId(), source.appCode(),
|
||||
source.requestedBy(), String.valueOf(source.dispatchVersion()),
|
||||
request.keyword() == null ? "" : request.keyword(),
|
||||
request.status() == null ? "" : request.status(), String.valueOf(maximumId));
|
||||
}
|
||||
|
||||
private byte[] hmac(String value) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256"));
|
||||
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("HmacSHA256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> row(IamTenant tenant) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", String.valueOf(tenant.getId()));
|
||||
row.put("tenantCode", tenant.getTenantCode());
|
||||
row.put("tenantName", tenant.getTenantName());
|
||||
row.put("legalName", tenant.getLegalName());
|
||||
row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode());
|
||||
row.put("enterpriseType", tenant.getEnterpriseType());
|
||||
row.put("establishedDate", tenant.getEstablishedDate());
|
||||
row.put("registeredCapital", tenant.getRegisteredCapital());
|
||||
row.put("registeredCapitalCurrency", tenant.getRegisteredCapitalCurrency());
|
||||
row.put("registeredAddress", tenant.getRegisteredAddress());
|
||||
row.put("contactName", tenant.getContactName());
|
||||
row.put("contactPhone", tenant.getContactPhone());
|
||||
row.put("contactEmail", tenant.getContactEmail());
|
||||
row.put("contactWechat", tenant.getContactWechat());
|
||||
row.put("salesOwnerName", tenant.getSalesOwnerName());
|
||||
row.put("servicePackage", tenant.getServicePackage());
|
||||
row.put("paymentTermDays", tenant.getPaymentTermDays());
|
||||
row.put("status", statusLabel(tenant.getStatus()));
|
||||
row.put("createdAt", tenant.getCreatedAt());
|
||||
row.put("approvedAt", tenant.getApprovedAt());
|
||||
return row;
|
||||
}
|
||||
|
||||
private String statusLabel(String status) {
|
||||
if (status == null) return null;
|
||||
return switch (status) {
|
||||
case "PROFILE_REQUIRED" -> "待完善";
|
||||
case "PENDING" -> "待审核";
|
||||
case "ACTIVE" -> "启用";
|
||||
case "REJECTED" -> "已驳回";
|
||||
case "DISABLED" -> "已停用";
|
||||
default -> status;
|
||||
};
|
||||
}
|
||||
|
||||
private static ExportColumn column(String key, String title, ExportColumnType type, int width) {
|
||||
return new ExportColumn(key, title, type, width);
|
||||
}
|
||||
|
||||
private static String schemaHash(List<ExportColumn> columns) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
for (ExportColumn column : columns) {
|
||||
digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type()
|
||||
+ "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest());
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new ExceptionInInitializerError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseCursor(String value) {
|
||||
String normalized = normalize(value);
|
||||
if (normalized == null) return null;
|
||||
try {
|
||||
long parsed = Long.parseLong(normalized);
|
||||
if (parsed < 0) throw invalid("cursor must not be negative");
|
||||
return parsed;
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid("cursor must be a numeric ID");
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositiveId(String value, String field) {
|
||||
try {
|
||||
if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID");
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid(field + " must be a positive ID");
|
||||
}
|
||||
}
|
||||
|
||||
private String optionalString(Object value, String field) {
|
||||
if (value == null) return null;
|
||||
if (!(value instanceof String text)) throw invalid(field + " must be a string");
|
||||
return normalize(text);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private BizException invalid(String message) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
|
||||
private record ValidatedRequest(ExportBatchRequest source, String keyword, String status,
|
||||
Long cursor, String cursorText, String snapshotToken) {
|
||||
int limit() { return source.limit(); }
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import com.baiyue.iam.entity.IamMembershipRole;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamTenantAddress;
|
||||
import com.baiyue.iam.entity.IamTenantQualification;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamMembershipMapper;
|
||||
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantAddressMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantQualificationMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantReviewMapper;
|
||||
import com.baiyue.iam.mapper.IamUserMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
@@ -24,6 +28,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class MerchantOnboardingService {
|
||||
@@ -33,6 +38,8 @@ public class MerchantOnboardingService {
|
||||
@Resource private IamRoleMapper roleMapper;
|
||||
@Resource private IamMembershipRoleMapper membershipRoleMapper;
|
||||
@Resource private IamTenantAddressMapper addressMapper;
|
||||
@Resource private IamTenantQualificationMapper qualificationMapper;
|
||||
@Resource private IamTenantReviewMapper reviewMapper;
|
||||
|
||||
@Transactional
|
||||
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
|
||||
@@ -61,7 +68,8 @@ public class MerchantOnboardingService {
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setLegalName(request.companyName());
|
||||
tenant.setContactName(request.legalPerson());
|
||||
tenant.setLegalRepresentative(request.legalPerson());
|
||||
tenant.setContactName(request.displayName());
|
||||
tenant.setContactPhone(request.legalPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
tenant.setDeleted(false);
|
||||
@@ -112,4 +120,51 @@ public class MerchantOnboardingService {
|
||||
}
|
||||
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a just-created IAM registration when Auth could not persist the paired login account.
|
||||
* Repeated calls are safe, while progressed onboarding data is deliberately protected.
|
||||
*/
|
||||
@Transactional
|
||||
public void compensateRegistration(long authUserId) {
|
||||
IamUser user = userMapper.selectOne(new LambdaQueryWrapper<IamUser>()
|
||||
.eq(IamUser::getAuthUserId, authUserId)
|
||||
.last("LIMIT 1 FOR UPDATE"));
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
List<IamMembership> memberships = membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getUserId, user.getId())
|
||||
.orderByAsc(IamMembership::getId)
|
||||
.last("FOR UPDATE"));
|
||||
if (memberships.size() != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"IAM registration compensation is unsafe: membership state changed");
|
||||
}
|
||||
IamMembership membership = memberships.get(0);
|
||||
IamTenant tenant = tenantMapper.selectOne(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getId, membership.getTenantId())
|
||||
.last("FOR UPDATE"));
|
||||
if (tenant == null || !"PROFILE_REQUIRED".equals(tenant.getStatus())
|
||||
|| !"OWNER".equals(membership.getMemberType())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"IAM registration compensation is unsafe: tenant state changed");
|
||||
}
|
||||
long qualificationCount = qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>()
|
||||
.eq(IamTenantQualification::getTenantId, tenant.getId()));
|
||||
long reviewCount = reviewMapper.selectCount(new LambdaQueryWrapper<IamTenantReview>()
|
||||
.eq(IamTenantReview::getTenantId, tenant.getId()));
|
||||
if (qualificationCount > 0 || reviewCount > 0) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"IAM registration compensation is unsafe: onboarding has progressed");
|
||||
}
|
||||
|
||||
membershipRoleMapper.delete(new LambdaQueryWrapper<IamMembershipRole>()
|
||||
.eq(IamMembershipRole::getMembershipId, membership.getId()));
|
||||
membershipMapper.deleteById(membership.getId());
|
||||
addressMapper.delete(new LambdaQueryWrapper<IamTenantAddress>()
|
||||
.eq(IamTenantAddress::getTenantId, tenant.getId()));
|
||||
userMapper.deleteById(user.getId());
|
||||
tenantMapper.deleteById(tenant.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ public class ProfileResourceService {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||
&& isCurrentQualification(item))
|
||||
@@ -82,12 +83,12 @@ public class ProfileResourceService {
|
||||
repository.saveQualification(existing);
|
||||
}
|
||||
IamTenantQualification qualification = createQualification(tenant.getId(),
|
||||
request.qualificationType(), request.certificateNo(), request.documentUrl());
|
||||
request.qualificationType(), certificateNo, request.documentUrl());
|
||||
qualification.setValidTo(request.validTo());
|
||||
repository.saveQualification(qualification);
|
||||
if (StringUtils.hasText(request.certificateNo())
|
||||
&& request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) {
|
||||
tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase());
|
||||
if ("BUSINESS_LICENSE".equals(request.qualificationType())
|
||||
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(certificateNo);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
@@ -104,6 +105,7 @@ public class ProfileResourceService {
|
||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||
&& isCurrentQualification(item))
|
||||
@@ -121,13 +123,36 @@ public class ProfileResourceService {
|
||||
repository.saveQualification(existing);
|
||||
}
|
||||
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
|
||||
request.certificateNo(), request.documentUrl());
|
||||
certificateNo, request.documentUrl());
|
||||
qualification.setValidTo(request.validTo());
|
||||
repository.saveQualification(qualification);
|
||||
if ("BUSINESS_LICENSE".equals(request.qualificationType())
|
||||
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(certificateNo);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new PlatformQualificationRegistrationResponse(
|
||||
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
}
|
||||
|
||||
private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) {
|
||||
if (!"BUSINESS_LICENSE".equals(qualificationType)) {
|
||||
return submittedCertificateNo;
|
||||
}
|
||||
String certificateNo = StringUtils.hasText(submittedCertificateNo)
|
||||
? submittedCertificateNo.trim().toUpperCase()
|
||||
: tenant.getUnifiedSocialCreditCode();
|
||||
if (!StringUtils.hasText(certificateNo) || !certificateNo.matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"A valid 18-character unified social credit code is required / 营业执照必须填写有效的18位统一社会信用代码");
|
||||
}
|
||||
if (tenantRepository.existsByCreditCodeForOtherTenant(certificateNo, tenant.getId())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Unified social credit code already exists / 统一社会信用代码已存在");
|
||||
}
|
||||
return certificateNo;
|
||||
}
|
||||
|
||||
private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) {
|
||||
IamTenantQualification x = new IamTenantQualification();
|
||||
x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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.AuthAccountClient;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Provisions the first enterprise owner while keeping credentials in Auth only. */
|
||||
@Service
|
||||
@Order(100)
|
||||
public class TenantAdministratorProvisioningService implements TenantApprovalInitializer {
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantAdministratorProvisioningService.class);
|
||||
private static final char[] UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray();
|
||||
private static final char[] LOWER = "abcdefghijkmnopqrstuvwxyz".toCharArray();
|
||||
private static final char[] DIGITS = "23456789".toCharArray();
|
||||
private static final char[] ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray();
|
||||
private static final int PASSWORD_LENGTH = 16;
|
||||
private static final int USERNAME_SUFFIX_LENGTH = 8;
|
||||
private static final int USERNAME_ATTEMPTS = 3;
|
||||
|
||||
private final MemberRepository members;
|
||||
private final IamRoleMapper roles;
|
||||
private final AuthAccountClient authClient;
|
||||
private final String internalToken;
|
||||
private final SecureRandom random;
|
||||
|
||||
public TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles,
|
||||
AuthAccountClient authClient,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this(members, roles, authClient, internalToken, new SecureRandom());
|
||||
}
|
||||
|
||||
TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles,
|
||||
AuthAccountClient authClient, String internalToken,
|
||||
SecureRandom random) {
|
||||
this.members = members;
|
||||
this.roles = roles;
|
||||
this.authClient = authClient;
|
||||
this.internalToken = internalToken;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() { return "tenant-administrator"; }
|
||||
|
||||
@Override
|
||||
public ProvisioningResult initialize(ApprovalContext context) {
|
||||
IamTenant tenant = context.tenant();
|
||||
long operatorId = context.reviewerId();
|
||||
IamRole ownerRole = requiredOwnerRole();
|
||||
IamMembership existingOwner = members.findOwnerByTenant(tenant.getId());
|
||||
if (existingOwner != null) return ensureExistingOwnerCanLogin(tenant, existingOwner, ownerRole);
|
||||
if (!isPlatformCreatedTenant(tenant)) {
|
||||
throw inconsistentOwner("客户端注册企业缺少原注册所有者,不能自动创建第二个管理员,请先修复注册身份");
|
||||
}
|
||||
|
||||
long authUserId = IdWorker.getId();
|
||||
long userId = IdWorker.getId();
|
||||
long membershipId = IdWorker.getId();
|
||||
String password = temporaryPassword();
|
||||
String username = createAuthAccountWithUniqueUsername(authUserId, password, tenant, membershipId);
|
||||
ProvisioningResult result = ProvisioningResult.created(authUserId, username, password);
|
||||
|
||||
try {
|
||||
IamUser user = new IamUser();
|
||||
user.setId(userId);
|
||||
user.setAuthUserId(authUserId);
|
||||
user.setUserType("ENTERPRISE");
|
||||
user.setDisplayName(tenant.getContactName());
|
||||
user.setPhone(tenant.getContactPhone());
|
||||
user.setEmail(tenant.getContactEmail());
|
||||
user.setStatus("ACTIVE");
|
||||
members.insertUser(user);
|
||||
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(membershipId);
|
||||
membership.setTenantId(tenant.getId());
|
||||
membership.setUserId(userId);
|
||||
membership.setMemberType("OWNER");
|
||||
membership.setMemberNo("OWNER-" + membershipId);
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setJoinedAt(LocalDateTime.now());
|
||||
membership.setCreatedBy(operatorId);
|
||||
membership.setUpdatedBy(operatorId);
|
||||
members.insert(membership);
|
||||
members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId()));
|
||||
return result;
|
||||
} catch (RuntimeException localFailure) {
|
||||
compensate(context, result);
|
||||
throw localFailure;
|
||||
}
|
||||
}
|
||||
|
||||
private IamRole requiredOwnerRole() {
|
||||
IamRole ownerRole = roles.selectOne(new LambdaQueryWrapper<IamRole>()
|
||||
.eq(IamRole::getTenantId, 0L)
|
||||
.eq(IamRole::getAppCode, "MERCHANT")
|
||||
.eq(IamRole::getRoleCode, "MERCHANT_OWNER")
|
||||
.eq(IamRole::getStatus, "ACTIVE")
|
||||
.last("LIMIT 1"));
|
||||
if (ownerRole == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能审核通过");
|
||||
}
|
||||
return ownerRole;
|
||||
}
|
||||
|
||||
private boolean isPlatformCreatedTenant(IamTenant tenant) {
|
||||
// 只有平台创建接口会记录操作人;客户端注册后也会补齐信用代码,不能据此推断来源。
|
||||
return tenant.getCreatedBy() != null;
|
||||
}
|
||||
|
||||
private ProvisioningResult ensureExistingOwnerCanLogin(IamTenant tenant, IamMembership owner,
|
||||
IamRole ownerRole) {
|
||||
if (!"ACTIVE".equals(owner.getStatus())) {
|
||||
throw inconsistentOwner("企业所有者成员已停用,不能审核通过");
|
||||
}
|
||||
IamUser user = members.findUser(owner.getUserId());
|
||||
if (user == null || user.getAuthUserId() == null || !"ACTIVE".equals(user.getStatus())) {
|
||||
throw inconsistentOwner("企业所有者身份不完整或已停用,不能审核通过");
|
||||
}
|
||||
|
||||
AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId());
|
||||
if (account == null) {
|
||||
String password = temporaryPassword();
|
||||
String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password,
|
||||
tenant, owner.getId());
|
||||
return ProvisioningResult.created(user.getAuthUserId(), username, password);
|
||||
}
|
||||
if (!"ACTIVE".equals(account.status())
|
||||
|| !user.getAuthUserId().equals(account.accountId())
|
||||
|| !tenant.getId().equals(account.tenantId())
|
||||
|| !owner.getId().equals(account.membershipId())
|
||||
|| !"MERCHANT".equals(account.appCode())) {
|
||||
throw inconsistentOwner("企业所有者认证账号已停用或账号范围不一致,不能审核通过");
|
||||
}
|
||||
List<Long> roleIds = members.findRoleIds(tenant.getId(), owner.getId());
|
||||
if (!roleIds.contains(ownerRole.getId())) {
|
||||
List<Long> repairedRoleIds = new java.util.ArrayList<>(roleIds);
|
||||
repairedRoleIds.add(ownerRole.getId());
|
||||
members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList());
|
||||
}
|
||||
return ProvisioningResult.existing();
|
||||
}
|
||||
|
||||
private AuthAccountClient.AccountSummary findAuthAccount(long authUserId) {
|
||||
try {
|
||||
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.summary(internalToken, authUserId);
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
if (code == ResultCode.NOT_FOUND) return null;
|
||||
throw new BizException(code, response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message());
|
||||
}
|
||||
if (response.data() == null) throw unavailable();
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 404) return null;
|
||||
throw unavailable();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void compensate(ApprovalContext context, InitializationResult initialization) {
|
||||
ProvisioningResult result = (ProvisioningResult) initialization;
|
||||
if (result.created()) compensate(result.authUserId());
|
||||
}
|
||||
|
||||
private String createAuthAccountWithUniqueUsername(long authUserId, String password,
|
||||
IamTenant tenant, long membershipId) {
|
||||
for (int attempt = 0; attempt < USERNAME_ATTEMPTS; attempt++) {
|
||||
String username = username(tenant.getId());
|
||||
try {
|
||||
createAuthAccount(authUserId, username, password, tenant, membershipId);
|
||||
return username;
|
||||
} catch (BizException exception) {
|
||||
if (exception.getResultCode() != ResultCode.CONFLICT || attempt == USERNAME_ATTEMPTS - 1) {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Username generation attempts exhausted");
|
||||
}
|
||||
|
||||
private void createAuthAccount(long authUserId, String username, String password,
|
||||
IamTenant tenant, long membershipId) {
|
||||
try {
|
||||
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken,
|
||||
new AuthAccountClient.CreateAccountRequest(authUserId, username, password,
|
||||
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
throw new BizException(code, response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message());
|
||||
}
|
||||
if (response.data() == null) throw unavailable();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) {
|
||||
throw new BizException(ResultCode.CONFLICT, "自动生成的企业管理员登录账号已存在");
|
||||
}
|
||||
throw unavailable();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private void compensate(long authUserId) {
|
||||
try {
|
||||
ApiResponse<Void> response = authClient.compensate(internalToken, authUserId);
|
||||
if (response == null || !response.successful()) throw unavailable();
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("企业管理员创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"企业管理员创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private String temporaryPassword() {
|
||||
char[] value = new char[PASSWORD_LENGTH];
|
||||
value[0] = pick(UPPER);
|
||||
value[1] = pick(LOWER);
|
||||
value[2] = pick(DIGITS);
|
||||
for (int index = 3; index < value.length; index++) value[index] = pick(ALL);
|
||||
for (int index = value.length - 1; index > 0; index--) {
|
||||
int swap = random.nextInt(index + 1);
|
||||
char current = value[index];
|
||||
value[index] = value[swap];
|
||||
value[swap] = current;
|
||||
}
|
||||
return new String(value);
|
||||
}
|
||||
|
||||
private String username(long tenantId) {
|
||||
char[] suffix = new char[USERNAME_SUFFIX_LENGTH];
|
||||
for (int index = 0; index < suffix.length; index++) suffix[index] = pick(LOWER);
|
||||
return "merchant_" + tenantId + "_" + new String(suffix);
|
||||
}
|
||||
|
||||
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能审核通过");
|
||||
}
|
||||
private BizException inconsistentOwner(String message) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
|
||||
public static final class ProvisioningResult implements InitializationResult {
|
||||
private final boolean created;
|
||||
private final Long authUserId;
|
||||
private final String username;
|
||||
private final String temporaryPassword;
|
||||
|
||||
private ProvisioningResult(boolean created, Long authUserId, String username, String temporaryPassword) {
|
||||
this.created = created;
|
||||
this.authUserId = authUserId;
|
||||
this.username = username;
|
||||
this.temporaryPassword = temporaryPassword;
|
||||
}
|
||||
|
||||
static ProvisioningResult existing() { return new ProvisioningResult(false, null, null, null); }
|
||||
static ProvisioningResult created(long authUserId, String username, String password) {
|
||||
return new ProvisioningResult(true, authUserId, username, password);
|
||||
}
|
||||
|
||||
public boolean created() { return created; }
|
||||
public Long authUserId() { return authUserId; }
|
||||
public String username() { return username; }
|
||||
public String temporaryPassword() { return temporaryPassword; }
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Runs all tenant approval initializers and owns their reverse-order compensation lifecycle. */
|
||||
@Service
|
||||
public class TenantApprovalInitializationCoordinator {
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantApprovalInitializationCoordinator.class);
|
||||
private final List<TenantApprovalInitializer> initializers;
|
||||
|
||||
public TenantApprovalInitializationCoordinator(List<TenantApprovalInitializer> initializers) {
|
||||
List<TenantApprovalInitializer> ordered = new ArrayList<>(initializers);
|
||||
AnnotationAwareOrderComparator.sort(ordered);
|
||||
Set<String> names = new HashSet<>();
|
||||
for (TenantApprovalInitializer initializer : ordered) {
|
||||
String name = initializer.name();
|
||||
if (name == null || name.isBlank() || !names.add(name)) {
|
||||
throw new IllegalArgumentException("Tenant approval initializer names must be nonblank and unique: " + name);
|
||||
}
|
||||
}
|
||||
this.initializers = List.copyOf(ordered);
|
||||
}
|
||||
|
||||
public InitializationBatch initialize(TenantApprovalInitializer.ApprovalContext context) {
|
||||
InitializationBatch batch = new InitializationBatch(context);
|
||||
registerRollbackCompensation(batch);
|
||||
try {
|
||||
for (TenantApprovalInitializer initializer : initializers) {
|
||||
TenantApprovalInitializer.InitializationResult result = initializer.initialize(context);
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Tenant approval initializer returned null: " + initializer.name());
|
||||
}
|
||||
batch.add(initializer, result);
|
||||
}
|
||||
return batch;
|
||||
} catch (RuntimeException initializationFailure) {
|
||||
try {
|
||||
compensate(batch);
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
compensationFailure.addSuppressed(initializationFailure);
|
||||
throw compensationFailure;
|
||||
}
|
||||
throw initializationFailure;
|
||||
}
|
||||
}
|
||||
|
||||
public InitializationBatch empty(TenantApprovalInitializer.ApprovalContext context) {
|
||||
return new InitializationBatch(context);
|
||||
}
|
||||
|
||||
public void compensate(InitializationBatch batch) {
|
||||
if (!batch.markCompensated()) return;
|
||||
RuntimeException firstFailure = null;
|
||||
List<CompletedInitialization> completed = new ArrayList<>(batch.completed());
|
||||
Collections.reverse(completed);
|
||||
for (CompletedInitialization item : completed) {
|
||||
try {
|
||||
item.initializer().compensate(batch.context(), item.result());
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("企业审核初始化补偿失败,initializer={}, tenantId={}",
|
||||
item.initializer().name(), batch.context().tenant().getId(), exception);
|
||||
if (firstFailure == null) firstFailure = exception;
|
||||
else firstFailure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
if (firstFailure != null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"企业审核初始化补偿失败,请检查服务日志并修复数据一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(InitializationBatch batch) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) return;
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status == STATUS_ROLLED_BACK) compensate(batch);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record CompletedInitialization(TenantApprovalInitializer initializer,
|
||||
TenantApprovalInitializer.InitializationResult result) { }
|
||||
|
||||
public static final class InitializationBatch {
|
||||
private final TenantApprovalInitializer.ApprovalContext context;
|
||||
private final List<CompletedInitialization> completed = new ArrayList<>();
|
||||
private final AtomicBoolean compensated = new AtomicBoolean(false);
|
||||
|
||||
private InitializationBatch(TenantApprovalInitializer.ApprovalContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private void add(TenantApprovalInitializer initializer,
|
||||
TenantApprovalInitializer.InitializationResult result) {
|
||||
completed.add(new CompletedInitialization(initializer, result));
|
||||
}
|
||||
|
||||
private TenantApprovalInitializer.ApprovalContext context() { return context; }
|
||||
private List<CompletedInitialization> completed() { return List.copyOf(completed); }
|
||||
private boolean markCompensated() { return compensated.compareAndSet(false, true); }
|
||||
|
||||
public <T extends TenantApprovalInitializer.InitializationResult> Optional<T> result(Class<T> type) {
|
||||
return completed.stream().map(CompletedInitialization::result)
|
||||
.filter(type::isInstance).map(type::cast).findFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
|
||||
/**
|
||||
* Extension point for provisioning defaults after an enterprise is approved.
|
||||
* Implementations may initialize accounts, roles, rules, fee plans or other tenant-scoped defaults.
|
||||
* Implementations and their compensation must be idempotent. A failed implementation must clean
|
||||
* up its own partial work before throwing; the coordinator compensates every initializer that
|
||||
* completed successfully, in reverse order. Use {@code @Order} for explicit dependencies.
|
||||
*/
|
||||
public interface TenantApprovalInitializer {
|
||||
String name();
|
||||
|
||||
InitializationResult initialize(ApprovalContext context);
|
||||
|
||||
default void compensate(ApprovalContext context, InitializationResult result) { }
|
||||
|
||||
record ApprovalContext(IamTenant tenant, long reviewId, long reviewerId, String comment) { }
|
||||
|
||||
interface InitializationResult { }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.TenantResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
|
||||
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
|
||||
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
|
||||
|
||||
@@ -39,6 +40,8 @@ public class TenantService {
|
||||
private TenantRepository repository;
|
||||
@Resource
|
||||
private ProfileResourceRepository profileRepository;
|
||||
@Resource
|
||||
private TenantApprovalInitializationCoordinator approvalInitialization;
|
||||
|
||||
|
||||
@BusinessAudit(
|
||||
@@ -49,7 +52,7 @@ public class TenantService {
|
||||
captureAfter = false
|
||||
)
|
||||
@Transactional
|
||||
public TenantResponse register(RegisterRequest request) {
|
||||
public TenantResponse register(RegisterRequest request, long operatorId) {
|
||||
if (repository.existsByCreditCode(request.unifiedSocialCreditCode())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在");
|
||||
}
|
||||
@@ -61,6 +64,7 @@ public class TenantService {
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
@@ -75,6 +79,8 @@ public class TenantService {
|
||||
tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency());
|
||||
tenant.setRegisteredAddress(request.registeredAddress());
|
||||
tenant.setInternalRemark(request.internalRemark());
|
||||
tenant.setCreatedBy(operatorId);
|
||||
tenant.setUpdatedBy(operatorId);
|
||||
repository.insertTenant(tenant);
|
||||
|
||||
return response(tenant);
|
||||
@@ -119,8 +125,8 @@ public class TenantService {
|
||||
captureAfter = false
|
||||
)
|
||||
@Transactional
|
||||
public void review(long reviewId, long reviewerId, boolean approve, String comment) {
|
||||
IamTenantReview review = repository.findReview(reviewId);
|
||||
public ApprovalResponse review(long reviewId, long reviewerId, boolean approve, String comment) {
|
||||
IamTenantReview review = repository.findReviewForUpdate(reviewId);
|
||||
if (review == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Review not found / 审核单不存在");
|
||||
}
|
||||
@@ -135,28 +141,49 @@ public class TenantService {
|
||||
}
|
||||
}
|
||||
|
||||
review.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
review.setReviewerId(reviewerId);
|
||||
review.setReviewComment(comment);
|
||||
review.setReviewedAt(LocalDateTime.now());
|
||||
repository.saveReview(review);
|
||||
|
||||
IamTenant tenant = repository.findTenant(review.getTenantId());
|
||||
if (tenant == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
|
||||
}
|
||||
tenant.setStatus(approve ? "ACTIVE" : "REJECTED");
|
||||
tenant.setRejectReason(approve ? null : comment);
|
||||
tenant.setApprovedAt(approve ? LocalDateTime.now() : null);
|
||||
tenant.setApprovedBy(approve ? reviewerId : null);
|
||||
repository.saveTenant(tenant);
|
||||
for (var qualification : profileRepository.findQualifications(tenant.getId())) {
|
||||
if ("PENDING".equals(qualification.getStatus())) {
|
||||
qualification.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
qualification.setReviewComment(comment);
|
||||
profileRepository.saveQualification(qualification);
|
||||
|
||||
var initializationContext = new TenantApprovalInitializer.ApprovalContext(
|
||||
tenant, reviewId, reviewerId, comment);
|
||||
var initialization = approve
|
||||
? approvalInitialization.initialize(initializationContext)
|
||||
: approvalInitialization.empty(initializationContext);
|
||||
var administrator = initialization
|
||||
.result(TenantAdministratorProvisioningService.ProvisioningResult.class)
|
||||
.orElseGet(TenantAdministratorProvisioningService.ProvisioningResult::existing);
|
||||
try {
|
||||
review.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
review.setReviewerId(reviewerId);
|
||||
review.setReviewComment(comment);
|
||||
review.setReviewedAt(LocalDateTime.now());
|
||||
repository.saveReview(review);
|
||||
|
||||
tenant.setStatus(approve ? "ACTIVE" : "REJECTED");
|
||||
tenant.setRejectReason(approve ? null : comment);
|
||||
tenant.setApprovedAt(approve ? LocalDateTime.now() : null);
|
||||
tenant.setApprovedBy(approve ? reviewerId : null);
|
||||
repository.saveTenant(tenant);
|
||||
for (var qualification : profileRepository.findQualifications(tenant.getId())) {
|
||||
if ("PENDING".equals(qualification.getStatus())) {
|
||||
qualification.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
qualification.setReviewComment(comment);
|
||||
profileRepository.saveQualification(qualification);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException localFailure) {
|
||||
try {
|
||||
approvalInitialization.compensate(initialization);
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
compensationFailure.addSuppressed(localFailure);
|
||||
throw compensationFailure;
|
||||
}
|
||||
throw localFailure;
|
||||
}
|
||||
return new ApprovalResponse(tenant.getId(), administrator.created(),
|
||||
administrator.username(), administrator.temporaryPassword());
|
||||
}
|
||||
|
||||
public OrderEligibilityResponse orderEligibility(long tenantId) {
|
||||
@@ -168,9 +195,9 @@ public class TenantService {
|
||||
return new OrderEligibilityResponse(false, tenant.getStatus(),
|
||||
"Enterprise information must be approved before ordering");
|
||||
}
|
||||
if (profileRepository.countApprovedQualifications(tenantId) < 1) {
|
||||
if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) {
|
||||
return new OrderEligibilityResponse(false, tenant.getStatus(),
|
||||
"At least one approved enterprise qualification is required");
|
||||
"All required enterprise qualifications must be approved");
|
||||
}
|
||||
return new OrderEligibilityResponse(true, tenant.getStatus(), null);
|
||||
}
|
||||
@@ -195,6 +222,7 @@ public class TenantService {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
@@ -237,6 +265,11 @@ public class TenantService {
|
||||
if (repository.findPendingReviewByTenant(tenantId) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
|
||||
}
|
||||
if (tenant.getUnifiedSocialCreditCode() == null
|
||||
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"A valid unified social credit code is required / 请先填写有效的18位统一社会信用代码");
|
||||
}
|
||||
var qualifications = profileRepository.findQualifications(tenantId);
|
||||
if (!hasAllPendingQualifications(qualifications)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
@@ -262,6 +295,7 @@ public class TenantService {
|
||||
}
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
@@ -274,8 +308,12 @@ public class TenantService {
|
||||
}
|
||||
|
||||
private boolean hasAllPendingQualifications(List<IamTenantQualification> qualifications) {
|
||||
return hasAllQualificationsWithStatus(qualifications, "PENDING");
|
||||
}
|
||||
|
||||
private boolean hasAllQualificationsWithStatus(List<IamTenantQualification> qualifications, String status) {
|
||||
return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream()
|
||||
.anyMatch(item -> type.equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())));
|
||||
.anyMatch(item -> type.equals(item.getQualificationType()) && status.equals(item.getStatus())));
|
||||
}
|
||||
|
||||
private IamTenant requireMerchant(long tenantId) {
|
||||
@@ -297,7 +335,7 @@ public class TenantService {
|
||||
private TenantResponse response(IamTenant tenant) {
|
||||
return new TenantResponse(
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
||||
tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
|
||||
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
|
||||
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
|
||||
tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(),
|
||||
tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(),
|
||||
@@ -308,7 +346,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.legalName(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
|
||||
response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(),
|
||||
response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(),
|
||||
@@ -318,7 +356,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.legalName(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
|
||||
response.paymentTermDays(), response.enterpriseType(), response.establishedDate(),
|
||||
response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(),
|
||||
|
||||
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED',
|
||||
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
|
||||
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
|
||||
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
|
||||
contact_name VARCHAR(64) NULL COMMENT '企业联系人',
|
||||
contact_phone VARCHAR(32) NULL COMMENT '联系电话',
|
||||
|
||||
@@ -79,7 +79,6 @@ VALUES
|
||||
(5104000, 0, 'PLATFORM', 'MENU', 0, 'platform:audit', '审计管理', '/audit', NULL, 50, 'ACTIVE', 0),
|
||||
-- Platform: level-two menus.
|
||||
(5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 0),
|
||||
(5100020, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:review', '企业审核', '/customers/reviews', NULL, 20, 'ACTIVE', 0),
|
||||
(5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 0),
|
||||
(5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0),
|
||||
(5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0),
|
||||
@@ -91,6 +90,7 @@ VALUES
|
||||
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0),
|
||||
(5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
|
||||
-- Platform: buttons.
|
||||
(5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0),
|
||||
(5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0),
|
||||
(5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0),
|
||||
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 0),
|
||||
|
||||
@@ -25,6 +25,7 @@ DELIMITER ;
|
||||
CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
|
||||
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
|
||||
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
|
||||
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
|
||||
@@ -55,11 +56,17 @@ ON DUPLICATE KEY UPDATE
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000100, id, 0
|
||||
FROM iam_permission
|
||||
WHERE app_code='MERCHANT' AND tenant_id=0
|
||||
AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND deleted=0;
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT owner_role.id, permission.id, 0
|
||||
FROM iam_role owner_role
|
||||
JOIN iam_permission permission
|
||||
ON permission.app_code='MERCHANT' AND permission.tenant_id=0
|
||||
AND permission.permission_type IN ('MENU','BUTTON')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0
|
||||
WHERE owner_role.tenant_id=0 AND owner_role.app_code='MERCHANT'
|
||||
AND owner_role.role_code='MERCHANT_OWNER'
|
||||
AND owner_role.status='ACTIVE' AND owner_role.deleted=0
|
||||
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
|
||||
|
||||
-- 不再写入伪造的资质 URL。资质必须先由 File 服务保存真实图片,再由 IAM 记录文件地址和审核结果。
|
||||
-- 已部署环境中的 /phase1-seed/ 历史记录按 docs/企业资质文件迁移说明.md 处理,本脚本不得将其改成不存在的 UUID。
|
||||
|
||||
@@ -173,7 +173,7 @@ paths:
|
||||
post:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: Approve enterprise registration / 审核通过企业注册
|
||||
description: Approve a pending review and activate the tenant. / 通过待审核申请并激活企业租户。
|
||||
description: Approve and activate the tenant. Client registrations keep the registration account as administrator. Only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活企业;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动创建管理员并返回一次性凭据。
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Id'}
|
||||
requestBody:
|
||||
@@ -182,7 +182,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ReviewRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 审核通过;data 返回开户结果及一次性初始凭据
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TenantApprovalApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
@@ -595,6 +599,41 @@ paths:
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/internal/exports/v1/CUSTOMER_LIST/batches:
|
||||
post:
|
||||
tags: [Internal Export Provider]
|
||||
summary: 分批提供企业客户导出数据
|
||||
description: 仅供 File Service 调用;按平台访问域、签名最大 ID 快照和游标只读查询商户客户。
|
||||
security: []
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
||||
properties:
|
||||
taskId: {type: string, pattern: '^\d+$'}
|
||||
tenantId: {type: string, pattern: '^\d+$'}
|
||||
appCode: {type: string, enum: [PLATFORM]}
|
||||
requestedBy: {type: string, pattern: '^\d+$'}
|
||||
dispatchVersion: {type: integer, minimum: 1}
|
||||
parameters: {type: object}
|
||||
cursor: {type: string, nullable: true}
|
||||
snapshotToken: {type: string, nullable: true, maxLength: 512}
|
||||
limit: {type: integer, minimum: 1, maximum: 500}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应;data 为 ExportBatchResponse,Long 值以字符串传输
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -677,6 +716,20 @@ components:
|
||||
size: {type: integer, format: int64, minimum: 1}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
TenantApprovalApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/TenantApprovalResponse'}
|
||||
TenantApprovalResponse:
|
||||
type: object
|
||||
required: [tenantId, administratorCreated]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'}
|
||||
administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员}
|
||||
administratorUsername: {type: string, nullable: true, example: merchant_190000000000000001_k3mnpqrs}
|
||||
temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回}
|
||||
TenantApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
@@ -704,6 +757,7 @@ components:
|
||||
tenantName: {type: string}
|
||||
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
unifiedSocialCreditCode: {type: string}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
@@ -763,10 +817,11 @@ components:
|
||||
reviewComment: {type: string}
|
||||
RegisterRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone, servicePackage]
|
||||
required: [tenantName, legalName, legalRepresentative, unifiedSocialCreditCode, contactName, contactPhone, servicePackage]
|
||||
properties:
|
||||
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalName: {type: string, example: 张三}
|
||||
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalRepresentative: {type: string, maxLength: 64, example: 张三}
|
||||
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Z]{18}$', example: 91440300MA5ABCDE12}
|
||||
contactName: {type: string, example: 李四}
|
||||
contactPhone: {type: string, example: '13800138000'}
|
||||
@@ -783,19 +838,21 @@ components:
|
||||
internalRemark: {type: string, maxLength: 1000}
|
||||
UpdateProfileRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, contactName, contactPhone, servicePackage]
|
||||
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
|
||||
properties:
|
||||
tenantName: {type: string}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactEmail: {type: string, format: email}
|
||||
UpdateTenantRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, contactName, contactPhone]
|
||||
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
|
||||
properties:
|
||||
tenantName: {type: string}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactEmail: {type: string, format: email}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.iam.service.CustomerListExportProviderService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CustomerListExportProviderControllerTest {
|
||||
@Test
|
||||
void exposesTheGenericCustomerListProviderPath() throws Exception {
|
||||
assertEquals("/internal/exports/v1/CUSTOMER_LIST",
|
||||
CustomerListExportProviderController.class.getAnnotation(RequestMapping.class).value()[0]);
|
||||
assertEquals("/batches", CustomerListExportProviderController.class
|
||||
.getMethod("batch", String.class, ExportBatchRequest.class)
|
||||
.getAnnotation(PostMapping.class).value()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresInternalTokenBeforeCallingProvider() {
|
||||
CustomerListExportProviderService service = mock(CustomerListExportProviderService.class);
|
||||
CustomerListExportProviderController controller =
|
||||
new CustomerListExportProviderController(service, "internal-token");
|
||||
ExportBatchRequest request = request();
|
||||
|
||||
BizException missing = assertThrows(BizException.class, () -> controller.batch(null, request));
|
||||
BizException wrong = assertThrows(BizException.class, () -> controller.batch("wrong", request));
|
||||
assertEquals(ResultCode.FORBIDDEN, missing.getResultCode());
|
||||
assertEquals(ResultCode.FORBIDDEN, wrong.getResultCode());
|
||||
|
||||
ExportBatchResponse batch = new ExportBatchResponse(1, "sha256:test", "snapshot",
|
||||
List.of(), List.of(), null, false, 0L);
|
||||
when(service.exportBatch(request)).thenReturn(batch);
|
||||
var response = controller.batch("internal-token", request);
|
||||
|
||||
assertEquals(ResultCode.SUCCESS.code(), response.code());
|
||||
assertEquals(batch, response.data());
|
||||
verify(service).exportBatch(request);
|
||||
}
|
||||
|
||||
private ExportBatchRequest request() {
|
||||
return new ExportBatchRequest("190000000000000001", "0", "PLATFORM",
|
||||
"190000000000000009", 1, Map.of(), null, null, 500);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user