This commit is contained in:
2026-08-04 09:05:28 +08:00
parent fdb1e8af1c
commit 3afcff320a
116 changed files with 6749 additions and 749 deletions
+2 -1
View File
@@ -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);
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 }
}
+4
View File
@@ -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>
+13
View File
@@ -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
+13
View File
@@ -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
+46
View File
@@ -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}
+8
View File
@@ -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
+5
View File
@@ -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
+11
View File
@@ -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__
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
+1
View File
@@ -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);
} catch (IOException ignored) {
return false;
}
Path parent = target.getParent();
if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) Files.deleteIfExists(parent);
} catch (IOException ignored) { }
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); }
}
}
+57 -2
View File
@@ -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:
+43
View File
@@ -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());
}
}
-2
View File
@@ -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
View File
@@ -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` 指定的统一迁移为准。
+8
View File
@@ -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; }
}
}
@@ -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,16 +141,26 @@ public class TenantService {
}
}
IamTenant tenant = repository.findTenant(review.getTenantId());
if (tenant == null) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
}
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);
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);
@@ -157,6 +173,17 @@ public class TenantService {
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(),
+1
View File
@@ -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。
+63 -6
View File
@@ -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}
@@ -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