From fdb1e8af1c8ad711e9c98caa02f829f40b6779ea Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 3 Aug 2026 18:28:35 +0800
Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/scripts/initialize-test-databases.sh | 3 +-
.../2026-07-31一期回归测试环境增量迁移.sql | 87 +++++++--
docs/公共导出与下载中心接入说明.md | 161 +++++++++++++++++
docs/头程项目目录结构与服务说明.md | 4 +-
file/pom.xml | 1 +
.../baiyue/file/FileServiceApplication.java | 2 +
.../baiyue/file/client/IamExportClient.java | 34 ++++
.../file/client/IamQualificationClient.java | 6 +-
.../PlatformExportTaskController.java | 81 +++++++++
.../QualificationFileController.java | 14 +-
.../com/baiyue/file/dto/ExportTaskDtos.java | 24 +++
.../baiyue/file/entity/FileExportTask.java | 37 ++++
.../file/mapper/FileExportTaskMapper.java | 8 +
.../file/repository/ExportTaskRepository.java | 132 ++++++++++++++
.../service/CustomerExportTaskHandler.java | 168 ++++++++++++++++++
.../file/service/ExportTaskHandler.java | 17 ++
.../file/service/ExportTaskService.java | 154 ++++++++++++++++
.../baiyue/file/service/ExportTaskWorker.java | 75 ++++++++
.../file/service/LocalExportStorage.java | 86 +++++++++
file/src/main/resources/application.yml | 4 +
file/src/main/resources/db/file.sql | 36 ++++
...tformExportTaskControllerContractTest.java | 23 +++
.../QualificationFileContractTest.java | 23 ++-
.../CustomerExportTaskHandlerTest.java | 55 ++++++
.../file/service/ExportTaskServiceTest.java | 65 +++++++
.../file/service/ExportTaskWorkerTest.java | 60 +++++++
.../controller/InternalTenantController.java | 18 ++
.../controller/MerchantTenantController.java | 7 +
.../baiyue/iam/dto/CustomerExportDtos.java | 20 +++
.../com/baiyue/iam/dto/OnboardingDtos.java | 9 +-
.../iam/repository/TenantRepository.java | 30 ++++
.../iam/service/ProfileResourceService.java | 68 ++++---
.../com/baiyue/iam/service/TenantService.java | 52 +++++-
.../main/resources/db/phase1-admin-rbac.sql | 6 +-
...-master-data-and-permission-management.sql | 30 +++-
iam/src/main/resources/openapi/iam-v1.yaml | 12 +-
.../InternalCustomerExportContractTest.java | 41 +++++
.../QualificationUploadContractTest.java | 1 +
.../CustomerProfileMigrationContractTest.java | 4 +-
.../MerchantQualificationUploadTest.java | 46 +++++
...PlatformQualificationRegistrationTest.java | 6 +-
.../iam/service/TenantCustomerExportTest.java | 42 +++++
.../baiyue/iam/service/TenantServiceTest.java | 24 ++-
43 files changed, 1707 insertions(+), 69 deletions(-)
create mode 100644 docs/公共导出与下载中心接入说明.md
create mode 100644 file/src/main/java/com/baiyue/file/client/IamExportClient.java
create mode 100644 file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java
create mode 100644 file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java
create mode 100644 file/src/main/java/com/baiyue/file/entity/FileExportTask.java
create mode 100644 file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java
create mode 100644 file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java
create mode 100644 file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskService.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java
create mode 100644 file/src/main/java/com/baiyue/file/service/LocalExportStorage.java
create mode 100644 file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java
create mode 100644 iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java
create mode 100644 iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java
diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh
index c39da79b0..9e97a52b6 100644
--- a/deploy/scripts/initialize-test-databases.sh
+++ b/deploy/scripts/initialize-test-databases.sh
@@ -33,7 +33,8 @@ for sql_file in \
warehouse/src/main/resources/db/warehouse-partner.sql \
warehouse/src/main/resources/db/warehouse-express-company.sql \
warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \
- warehouse/src/main/resources/db/warehouse-seed.sql
+ warehouse/src/main/resources/db/warehouse-seed.sql \
+ file/src/main/resources/db/file.sql
do
if [ ! -f "$sql_file" ]; then
echo "找不到初始化 SQL:$sql_file" >&2
diff --git a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql
index 0b775bc94..be5de8e48 100644
--- a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql
+++ b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql
@@ -1,6 +1,6 @@
-- 一期回归测试环境增量迁移汇总
-- 适用版本:MySQL 8.0
--- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg
+-- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg、baiyue_file
-- 安全边界:不创建数据库、不清表、不删除业务数据、不写入演示租户及演示业务数据。
-- 可重复执行:所有新增字段、表、索引和权限均带存在性判断或幂等写入。
-- 重要说明:MySQL DDL 会隐式提交,本脚本不能作为一个跨库事务整体回滚。
@@ -100,7 +100,8 @@ VALUES
(5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 1, 0),
(5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 1, 0),
(5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 1, 0),
- (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 1, 0)
+ (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 1, 0),
+ (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type),
parent_id = VALUES(parent_id),
@@ -120,6 +121,7 @@ VALUES
(5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', NULL, NULL, 15, 'ACTIVE', 1, 0),
(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),
(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),
@@ -146,7 +148,8 @@ VALUES
(5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', NULL, NULL, 30, 'ACTIVE', 1, 0),
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 1, 0),
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 1, 0),
- (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', NULL, NULL, 30, 'ACTIVE', 1, 0)
+ (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', NULL, NULL, 30, 'ACTIVE', 1, 0),
+ (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type),
parent_id = VALUES(parent_id),
@@ -203,7 +206,7 @@ SELECT r.id, p.id, 0
AND r.deleted = 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:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export',
'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',
@@ -218,7 +221,8 @@ SELECT r.id, p.id, 0
'platform:master-data', 'platform:express:list', 'platform:express:create',
'platform:express:edit', 'platform:express:status',
'platform:counterparty:list', 'platform:counterparty:create',
- 'platform:counterparty:edit', 'platform:counterparty:status'
+ 'platform:counterparty:edit', 'platform:counterparty:status',
+ 'platform:download:list', 'platform:download:all'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -237,6 +241,20 @@ SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenan
AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+-- 已有客户列表或审核角色获得客户导出及本人下载任务查询,不继承查看全部任务权限。
+INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id
+ FROM iam_role_permission existing_grant
+ JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id
+ JOIN iam_permission added_permission
+ ON added_permission.tenant_id = 0
+ AND added_permission.app_code = 'PLATFORM'
+ AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list')
+ WHERE existing_permission.tenant_id = 0
+ AND existing_permission.app_code = 'PLATFORM'
+ AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review')
+ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+
-- 已存在的商户超级管理员/企业管理员获得对应租户的角色维护权限。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT r.id, p.id, r.tenant_id
@@ -627,18 +645,59 @@ EXECUTE firstleg_task_list_index_stmt;
DEALLOCATE PREPARE firstleg_task_list_index_stmt;
-- ============================================================================
--- 5. 执行后只读核验
+-- 5. File:公共导出与下载中心任务表
+-- ============================================================================
+USE baiyue_file;
+
+CREATE TABLE IF NOT EXISTS file_export_task (
+ id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0',
+ app_code VARCHAR(24) NOT NULL COMMENT '申请端',
+ task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号',
+ export_type VARCHAR(64) NOT NULL COMMENT '导出类型',
+ format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式',
+ task_name VARCHAR(128) NOT NULL COMMENT '任务名称',
+ parameters_json JSON NOT NULL COMMENT '筛选条件快照',
+ status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
+ total_rows BIGINT UNSIGNED NULL,
+ processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0,
+ file_name VARCHAR(255) NULL,
+ object_key VARCHAR(512) NULL,
+ content_type VARCHAR(128) NULL,
+ size_bytes BIGINT UNSIGNED NULL,
+ failure_message VARCHAR(1000) NULL,
+ requested_by BIGINT UNSIGNED NOT NULL,
+ requested_username VARCHAR(128) NULL,
+ requested_at DATETIME(3) NOT NULL,
+ started_at DATETIME(3) NULL,
+ completed_at DATETIME(3) NULL,
+ expires_at DATETIME(3) NULL,
+ created_by BIGINT UNSIGNED NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_by BIGINT UNSIGNED NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ deleted TINYINT(1) NOT NULL DEFAULT 0,
+ version INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ 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_expiry (status, expires_at)
+) ENGINE=InnoDB COMMENT='公共异步导出任务表';
+
+-- ============================================================================
+-- 6. 执行后只读核验
-- ============================================================================
SELECT
'IAM权限资源' AS check_item,
COUNT(*) AS actual_count,
- 44 AS expected_count,
- IF(COUNT(*) = 44, '通过', '请检查缺失权限') AS check_result
+ 47 AS expected_count,
+ IF(COUNT(*) = 47, '通过', '请检查缺失权限') AS check_result
FROM baiyue_iam.iam_permission
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:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export',
'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',
@@ -653,7 +712,8 @@ WHERE deleted = 0
'platform:master-data', 'platform:express:list', 'platform:express:create',
'platform:express:edit', 'platform:express:status',
'platform:counterparty:list', 'platform:counterparty:create',
- 'platform:counterparty:edit', 'platform:counterparty:status'
+ 'platform:counterparty:edit', 'platform:counterparty:status',
+ 'platform:download:list', 'platform:download:all'
);
SELECT
@@ -705,5 +765,12 @@ WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_fulfillment_task'
AND index_name = 'idx_task_tenant_created';
+SELECT
+ IF(COUNT(*) = 1, '通过', '缺失') AS export_task_table,
+ 'baiyue_file.file_export_task' AS object_name
+FROM information_schema.tables
+WHERE table_schema = 'baiyue_file'
+ AND table_name = 'file_export_task';
+
SELECT 'MIGRATION_COMPLETED' AS migration_status,
'请确认上方所有核验结果均为通过后再启动服务回归' AS message;
diff --git a/docs/公共导出与下载中心接入说明.md b/docs/公共导出与下载中心接入说明.md
new file mode 100644
index 000000000..9e4885f1b
--- /dev/null
+++ b/docs/公共导出与下载中心接入说明.md
@@ -0,0 +1,161 @@
+# 公共导出与下载中心接入说明
+
+## 1. 目标与边界
+
+`file-service` 统一承载异步导出任务、XLSX 文件生成结果、过期清理和下载鉴权。业务服务仍是业务数据唯一事实来源,通过内部、只读、分批接口向 File 提供导出数据。
+
+- 浏览器不得拉取全部分页后自行生成 Excel。
+- File 不跨库查询 IAM、Order、Warehouse 等业务数据库。
+- 任务不保存用户 Bearer Token;服务间调用使用 `X-Internal-Service-Token`。
+- 当前首个导出类型为 `CUSTOMER_LIST`,数据由 IAM 提供。
+- 当前存储实现沿用 `FILE_STORAGE_ROOT` 下的共享目录;生产多实例必须挂载同一持久化目录,后续可替换为对象存储。
+
+## 2. 调用流程
+
+```text
+Admin/Client
+ -> POST /api/platform/v1/files/export-tasks
+ -> file_export_task(PENDING)
+ -> File worker 原子领取(RUNNING)
+ -> 业务服务 internal export API(最多 500 条/批)
+ -> SXSSFWorkbook 流式生成 XLSX
+ -> file_export_task(SUCCEEDED)
+ -> GET /api/platform/v1/files/export-tasks/{id}/download
+```
+
+任务状态只允许:
+
+```text
+PENDING -> RUNNING -> SUCCEEDED
+ -> FAILED -> PENDING(手工重试)
+SUCCEEDED -> EXPIRED
+```
+
+服务重启后数据库中的 `PENDING` 会继续处理;超过配置阈值的 `RUNNING` 会重新排队。多个 File 实例通过 `PENDING` 条件更新原子领取,只有一个实例能成功。
+
+## 3. 平台公开接口
+
+所有接口都经过 Gateway 的现有 `/api/platform/v1/files/**` 路由。
+
+### 3.1 创建导出任务
+
+`POST /api/platform/v1/files/export-tasks`
+
+```json
+{
+ "exportType": "CUSTOMER_LIST",
+ "format": "XLSX",
+ "taskName": "客户列表",
+ "parameters": {
+ "keyword": "柏悦",
+ "status": "ACTIVE"
+ }
+}
+```
+
+创建接口按 `exportType` 校验来源权限。客户导出要求 `platform:customer:export`。筛选参数在创建时形成快照,后续页面修改筛选条件不会改变已提交任务。
+
+### 3.2 下载中心列表
+
+`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}`
+
+请求头:
+
+```http
+X-Internal-Service-Token: ${INTERNAL_SERVICE_TOKEN}
+```
+
+响应:
+
+```json
+{
+ "code": "200",
+ "data": {
+ "records": [],
+ "nextCursor": 190000000000000001,
+ "hasMore": false
+ }
+}
+```
+
+IAM 按租户 ID 升序游标读取,只查询 `tenant_type=MERCHANT`,并复用客户列表的 `keyword/status` 语义。导出 DTO 与平台列表 DTO 分离,因此不会复用列表中的电话和邮箱脱敏结果。
+
+## 5. 新增其他导出类型
+
+以订单导出为例:
+
+1. 数据所属服务提供带内部令牌校验的只读游标接口,不允许 File 直接访问其数据库。
+2. 在 File 增加一个 `ExportTaskHandler` Bean:
+ - `exportType()` 返回稳定编码,例如 `FIRST_LEG_ORDER_LIST`;
+ - `requiredPermission()` 返回创建该任务所需的业务权限;
+ - `export(...)` 分批调用来源服务、流式写入文件并报告进度。
+3. 增加对应 Feign Client 和导出专用 DTO,不复用面向页面的脱敏或分页 DTO。
+4. 添加稳定权限资源、管理员授权和前端任务类型显示。
+5. 增加处理器 XLSX 内容测试、来源接口筛选/游标测试和前端提交测试。
+
+不要在公共 Controller 中增加按类型展开的业务查询分支;类型扩展通过 Spring 注入的 `ExportTaskHandler` 注册表完成。
+
+## 6. 存储、保留与配置
+
+```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}
+```
+
+导出文件位于 `${FILE_STORAGE_ROOT}/exports/{taskId}/{uuid}.xlsx`。`object_key` 必须匹配服务端固定格式,任意绝对路径、`..` 或非 XLSX 后缀都会被拒绝。任务过期后先删除文件,再标记 `EXPIRED`。
+
+## 7. SQL 与部署顺序
+
+新环境:
+
+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 静态资源。
+
+现有测试环境可执行 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql`。脚本幂等创建任务表并补齐:
+
+- `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 下载中心覆盖加载、空数据、失败、处理中、完成、失败、过期和窄屏操作状态。
diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md
index 498508392..215b125b4 100644
--- a/docs/头程项目目录结构与服务说明.md
+++ b/docs/头程项目目录结构与服务说明.md
@@ -83,7 +83,7 @@ baiyue-firstleg/
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务、操作分配和分配历史;不是订单主数据服务 |
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、仓库地址、服务能力、启停校验,以及往来单位查询;往来单位不单独拆服务 |
-| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;当前主要实现企业资质、营业执照上传和预览 |
+| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;实现企业资质上传预览及公共异步导出、下载中心 |
| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 |
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
@@ -177,6 +177,8 @@ IAM 以 `tenant_id` 作为核心隔离维度,同时维护两套访问域:商
- `QualificationFileController`:商户端企业资质文件
- `PlatformQualificationFileController`:平台端鉴权查看
+- `PlatformExportTaskController`:平台异步导出任务、下载与失败重试
+- `ExportTaskHandler`:业务导出类型扩展点;接入规则见 `docs/公共导出与下载中心接入说明.md`
- `QualificationFileStorage`:存储抽象
- `LocalQualificationStorage`:当前本地磁盘实现
- `IamQualificationClient`:校验企业资质访问权限
diff --git a/file/pom.xml b/file/pom.xml
index e9b118423..4f70fe49d 100644
--- a/file/pom.xml
+++ b/file/pom.xml
@@ -5,6 +5,7 @@
com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml
file-service
+ org.apache.poipoi-ooxml5.3.0
com.baiyue.sharedbaiyue-common-audit${project.version}
com.baiyue.sharedbaiyue-common-openapi${project.version}
com.baomidoumybatis-plus-spring-boot3-starter
diff --git a/file/src/main/java/com/baiyue/file/FileServiceApplication.java b/file/src/main/java/com/baiyue/file/FileServiceApplication.java
index d4de34b4b..4c3deb716 100644
--- a/file/src/main/java/com/baiyue/file/FileServiceApplication.java
+++ b/file/src/main/java/com/baiyue/file/FileServiceApplication.java
@@ -3,12 +3,14 @@ package com.baiyue.file;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 文件中心服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
@EnableFeignClients(basePackages = "com.baiyue.file.client")
+@EnableScheduling
public class FileServiceApplication {
public static void main(String[] args) {
SpringApplication.run(FileServiceApplication.class, args);
diff --git a/file/src/main/java/com/baiyue/file/client/IamExportClient.java b/file/src/main/java/com/baiyue/file/client/IamExportClient.java
new file mode 100644
index 000000000..fc48270c6
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/IamExportClient.java
@@ -0,0 +1,34 @@
+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 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 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) { }
+}
diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java
index 458b8b1b2..4d93ca066 100644
--- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java
+++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java
@@ -20,8 +20,10 @@ public interface IamQualificationClient {
@RequestBody PlatformQualificationRequest request);
record QualificationRequest(Long tenantId, String qualificationType,
- String certificateNo, String documentUrl) { }
- record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
+ String certificateNo, String documentUrl,
+ java.time.LocalDate validTo, boolean replace) { }
+ record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
+ String replacedDocumentUrl) { }
record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType,
diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java
new file mode 100644
index 000000000..21d95baf2
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java
@@ -0,0 +1,81 @@
+package com.baiyue.file.controller;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
+import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse;
+import com.baiyue.file.service.ExportTaskService;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+
+@RestController
+@RequestMapping("/api/platform/v1/files/export-tasks")
+@Validated
+public class PlatformExportTaskController {
+ private final ExportTaskService service;
+
+ public PlatformExportTaskController(ExportTaskService service) { this.service = service; }
+
+ @PostMapping
+ @PreAuthorize("isAuthenticated()")
+ public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,
+ @Valid @RequestBody CreateExportTaskRequest request) {
+ return ApiResponse.success(service.create(user, request));
+ }
+
+ @GetMapping
+ @PreAuthorize("hasAuthority('platform:download:list')")
+ public ApiResponse> page(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestParam(defaultValue = "1") @Min(1) long page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
+ @RequestParam(required = false) String exportType,
+ @RequestParam(required = false) @Pattern(regexp = "PENDING|RUNNING|SUCCEEDED|FAILED|EXPIRED") String status,
+ @RequestParam(required = false) LocalDate requestedFrom,
+ @RequestParam(required = false) LocalDate requestedTo) {
+ return ApiResponse.success(service.page(user, page, size, exportType, status, requestedFrom, requestedTo));
+ }
+
+ @GetMapping("/{id:\\d+}")
+ @PreAuthorize("hasAuthority('platform:download:list')")
+ public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable long id) {
+ return ApiResponse.success(service.get(user, id));
+ }
+
+ @GetMapping("/{id:\\d+}/download")
+ @PreAuthorize("hasAuthority('platform:download:list')")
+ public ResponseEntity download(
+ @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) {
+ var download = service.download(user, id);
+ ContentDisposition disposition = ContentDisposition.attachment()
+ .filename(download.fileName(), StandardCharsets.UTF_8).build();
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.noStore())
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .contentType(MediaType.parseMediaType(download.contentType()))
+ .body(download.resource());
+ }
+
+ @PostMapping("/{id:\\d+}/retry")
+ @PreAuthorize("hasAuthority('platform:download:list')")
+ public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable long id) {
+ return ApiResponse.success(service.retry(user, id));
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
index 7d431b72a..02044c6fe 100644
--- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
+++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
@@ -18,6 +18,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
+import java.time.LocalDate;
+
@RestController
@RequestMapping("/api/merchant/v1/files/qualifications")
public class QualificationFileController {
@@ -31,13 +33,16 @@ public class QualificationFileController {
public ApiResponse upload(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file,
- @RequestParam(value = "certificateNo", required = false) String certificateNo) {
+ @RequestParam(defaultValue = "BUSINESS_LICENSE") String qualificationType,
+ @RequestParam(value = "certificateNo", required = false) String certificateNo,
+ @RequestParam(required = false) LocalDate validTo,
+ @RequestParam(defaultValue = "false") boolean replace) {
long tenantId = merchantTenant(user);
var stored = storage.store(tenantId, file);
try {
var response = iamClient.submit(internalToken,
- new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
- certificateNo, stored.documentUrl()));
+ new IamQualificationClient.QualificationRequest(tenantId, qualificationType,
+ certificateNo, stored.documentUrl(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
@@ -52,6 +57,9 @@ public class QualificationFileController {
throw new IllegalStateException("IAM qualification response data is empty");
}
var data = response.data();
+ if (!stored.documentUrl().equals(data.replacedDocumentUrl())) {
+ storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl());
+ }
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
data.tenantStatus(), stored.documentUrl()));
} catch (BizException exception) {
diff --git a/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java
new file mode 100644
index 000000000..d1079c543
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java
@@ -0,0 +1,24 @@
+package com.baiyue.file.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+public final class ExportTaskDtos {
+ private ExportTaskDtos() { }
+
+ public record CreateExportTaskRequest(
+ @NotBlank @Size(max = 64) String exportType,
+ @NotBlank @Size(max = 16) String format,
+ @NotBlank @Size(max = 128) String taskName,
+ Map parameters) { }
+
+ public record ExportTaskResponse(
+ String id, String taskNo, String exportType, String format, String taskName,
+ String status, Long totalRows, long processedRows, String fileName,
+ String contentType, Long sizeBytes, String failureMessage,
+ String requestedBy, String requestedUsername, LocalDateTime requestedAt,
+ LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { }
+}
diff --git a/file/src/main/java/com/baiyue/file/entity/FileExportTask.java b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java
new file mode 100644
index 000000000..23fd60421
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java
@@ -0,0 +1,37 @@
+package com.baiyue.file.entity;
+
+import com.baiyue.common.core.model.BaseEntity;
+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_task")
+public class FileExportTask extends BaseEntity {
+ @TableId private Long id;
+ private Long tenantId;
+ private String appCode;
+ private String taskNo;
+ private String exportType;
+ private String format;
+ private String taskName;
+ private String parametersJson;
+ private String status;
+ private Long totalRows;
+ private Long processedRows;
+ private String fileName;
+ private String objectKey;
+ private String contentType;
+ private Long sizeBytes;
+ private String failureMessage;
+ private Long requestedBy;
+ private String requestedUsername;
+ private LocalDateTime requestedAt;
+ private LocalDateTime startedAt;
+ private LocalDateTime completedAt;
+ private LocalDateTime expiresAt;
+}
diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java
new file mode 100644
index 000000000..6ac096cf0
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java
@@ -0,0 +1,8 @@
+package com.baiyue.file.mapper;
+
+import com.baiyue.file.entity.FileExportTask;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FileExportTaskMapper extends BaseMapper { }
diff --git a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java
new file mode 100644
index 000000000..0455c953f
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java
@@ -0,0 +1,132 @@
+package com.baiyue.file.repository;
+
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.file.entity.FileExportTask;
+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 org.springframework.stereotype.Repository;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Repository
+public class ExportTaskRepository {
+ private final FileExportTaskMapper mapper;
+
+ public ExportTaskRepository(FileExportTaskMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ public void insert(FileExportTask task) { mapper.insert(task); }
+
+ public FileExportTask find(long id) { return mapper.selectById(id); }
+
+ public PageResult page(long page, long size, long tenantId, String appCode,
+ Long requestedBy, String exportType, String status,
+ LocalDate requestedFrom, LocalDate requestedTo) {
+ var query = new LambdaQueryWrapper()
+ .eq(FileExportTask::getTenantId, tenantId)
+ .eq(FileExportTask::getAppCode, appCode)
+ .eq(requestedBy != null, FileExportTask::getRequestedBy, requestedBy)
+ .eq(StringUtils.hasText(exportType), FileExportTask::getExportType, exportType)
+ .eq(StringUtils.hasText(status), FileExportTask::getStatus, status)
+ .ge(requestedFrom != null, FileExportTask::getRequestedAt,
+ requestedFrom == null ? null : requestedFrom.atStartOfDay())
+ .lt(requestedTo != null, FileExportTask::getRequestedAt,
+ requestedTo == null ? null : requestedTo.plusDays(1).atStartOfDay())
+ .orderByDesc(FileExportTask::getRequestedAt)
+ .orderByDesc(FileExportTask::getId);
+ return PageResult.from(mapper.selectPage(new Page<>(page, size), query));
+ }
+
+ public Long firstPendingId() {
+ FileExportTask task = mapper.selectOne(new LambdaQueryWrapper()
+ .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()
+ .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()
+ .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()
+ .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()
+ .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()
+ .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()
+ .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 expiredArtifacts(LocalDateTime now) {
+ return mapper.selectList(new LambdaQueryWrapper()
+ .eq(FileExportTask::getStatus, "SUCCEEDED")
+ .le(FileExportTask::getExpiresAt, now)
+ .isNotNull(FileExportTask::getObjectKey)
+ .last("LIMIT 50"));
+ }
+
+ public void markExpired(long id) {
+ mapper.update(null, new LambdaUpdateWrapper()
+ .eq(FileExportTask::getId, id)
+ .eq(FileExportTask::getStatus, "SUCCEEDED")
+ .set(FileExportTask::getStatus, "EXPIRED"));
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java b/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java
new file mode 100644
index 000000000..da0933569
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java
@@ -0,0 +1,168 @@
+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 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 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) { }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java b/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java
new file mode 100644
index 000000000..ba45ebd25
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java
@@ -0,0 +1,17 @@
+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 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); }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
new file mode 100644
index 000000000..15b1c6395
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
@@ -0,0 +1,154 @@
+package com.baiyue.file.service;
+
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.security.AuthenticatedUser;
+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.baomidou.mybatisplus.core.toolkit.IdWorker;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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;
+
+@Service
+public class ExportTaskService {
+ private final ExportTaskRepository repository;
+ private final LocalExportStorage storage;
+ private final ObjectMapper objectMapper;
+ private final Map handlers;
+
+ public ExportTaskService(ExportTaskRepository repository, LocalExportStorage storage,
+ ObjectMapper objectMapper, List handlers) {
+ this.repository = repository;
+ this.storage = storage;
+ this.objectMapper = objectMapper;
+ this.handlers = handlers.stream().collect(Collectors.toUnmodifiableMap(
+ handler -> handler.exportType().toUpperCase(Locale.ROOT), Function.identity()));
+ }
+
+ 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);
+ if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式");
+ if (!user.permissions().contains(handler.requiredPermission())) {
+ throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
+ }
+ handler.validateParameters(request.parameters() == null ? Map.of() : request.parameters());
+ long id = IdWorker.getId();
+ LocalDateTime now = LocalDateTime.now();
+ FileExportTask task = new FileExportTask();
+ task.setId(id);
+ task.setTenantId(user.tenantId());
+ task.setAppCode(user.appCode().toUpperCase(Locale.ROOT));
+ task.setTaskNo("EXP" + id);
+ task.setExportType(type);
+ task.setFormat(format);
+ task.setTaskName(request.taskName().trim());
+ task.setParametersJson(parametersJson(request.parameters()));
+ task.setStatus("PENDING");
+ task.setProcessedRows(0L);
+ task.setRequestedBy(user.userId());
+ task.setRequestedUsername(user.username());
+ task.setRequestedAt(now);
+ repository.insert(task);
+ return response(task);
+ }
+
+ public PageResult page(AuthenticatedUser user, long page, long size,
+ String exportType, String status,
+ LocalDate requestedFrom, LocalDate requestedTo) {
+ requirePlatform(user);
+ if (requestedFrom != null && requestedTo != null && requestedFrom.isAfter(requestedTo)) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "开始日期不能晚于结束日期");
+ }
+ Long owner = user.permissions().contains("platform:download:all") ? null : user.userId();
+ PageResult source = repository.page(page, size, user.tenantId(), user.appCode(), owner,
+ normalize(exportType), normalize(status), requestedFrom, requestedTo);
+ return new PageResult<>(source.records().stream().map(this::response).toList(),
+ source.page(), source.size(), source.total(), source.pages());
+ }
+
+ public ExportTaskResponse get(AuthenticatedUser user, long id) {
+ return response(ownedTask(user, id));
+ }
+
+ public DownloadResource download(AuthenticatedUser user, long id) {
+ FileExportTask task = ownedTask(user, id);
+ if (!"SUCCEEDED".equals(task.getStatus())) {
+ throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期");
+ }
+ if (task.getExpiresAt() != null && !task.getExpiresAt().isAfter(LocalDateTime.now())) {
+ throw new BizException(ResultCode.CONFLICT, "导出文件已过期");
+ }
+ return new DownloadResource(storage.load(task.getObjectKey()), task.getFileName(), task.getContentType());
+ }
+
+ public ExportTaskResponse retry(AuthenticatedUser user, long id) {
+ FileExportTask task = ownedTask(user, id);
+ ExportTaskHandler handler = handler(task.getExportType());
+ if (!user.permissions().contains(handler.requiredPermission())) {
+ throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
+ }
+ if (!"FAILED".equals(task.getStatus()) || !repository.retry(id)) {
+ 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);
+ if (task == null || !task.getTenantId().equals(user.tenantId())
+ || !task.getAppCode().equalsIgnoreCase(user.appCode())) {
+ throw new BizException(ResultCode.NOT_FOUND, "导出任务不存在");
+ }
+ if (!task.getRequestedBy().equals(user.userId()) && !user.permissions().contains("platform:download:all")) {
+ throw new BizException(ResultCode.FORBIDDEN, "不能访问其他用户的导出任务");
+ }
+ return task;
+ }
+
+ private String parametersJson(Map parameters) {
+ try { return objectMapper.writeValueAsString(parameters == null ? Map.of() : parameters); }
+ catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); }
+ }
+
+ private ExportTaskResponse response(FileExportTask task) {
+ return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
+ task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
+ task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(),
+ task.getContentType(), task.getSizeBytes(), task.getFailureMessage(),
+ String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(),
+ task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt());
+ }
+
+ private void requirePlatform(AuthenticatedUser user) {
+ if (user == null || user.userId() == null || user.tenantId() == null
+ || !"PLATFORM".equalsIgnoreCase(user.appCode())) {
+ throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
+ }
+ }
+
+ private String normalize(String value) { return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT); }
+
+ public record DownloadResource(org.springframework.core.io.Resource resource, String fileName, String contentType) { }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java b/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java
new file mode 100644
index 000000000..41168e92a
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java
@@ -0,0 +1,75 @@
+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);
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java
new file mode 100644
index 000000000..59adb5009
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java
@@ -0,0 +1,86 @@
+package com.baiyue.file.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import jakarta.annotation.PostConstruct;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.UrlResource;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.UUID;
+
+@Service
+public class LocalExportStorage {
+ private final Path root;
+ private final Path exportRoot;
+
+ public LocalExportStorage(@Value("${baiyue.file.storage-root}") String root) {
+ this.root = Path.of(root).toAbsolutePath().normalize();
+ this.exportRoot = this.root.resolve("exports").normalize();
+ }
+
+ @PostConstruct
+ void initialize() {
+ try {
+ Files.createDirectories(exportRoot);
+ if (!exportRoot.startsWith(root) || !Files.isDirectory(exportRoot) || !Files.isWritable(exportRoot)) {
+ throw new IOException("Export storage is not writable");
+ }
+ } catch (IOException exception) {
+ throw new IllegalStateException("导出文件存储目录不可用", exception);
+ }
+ }
+
+ public PendingFile create(long taskId) {
+ String objectKey = "exports/" + taskId + "/" + UUID.randomUUID() + ".xlsx";
+ Path target = resolve(objectKey);
+ try {
+ Files.createDirectories(target.getParent());
+ OutputStream output = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
+ return new PendingFile(objectKey, target, output);
+ } catch (IOException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件存储暂不可用");
+ }
+ }
+
+ public long size(String objectKey) {
+ try { return Files.size(resolve(objectKey)); }
+ catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件读取失败"); }
+ }
+
+ public Resource load(String objectKey) {
+ Path target = resolve(objectKey);
+ if (!Files.isRegularFile(target)) throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理");
+ try { return new UrlResource(target.toUri()); }
+ catch (Exception exception) { throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); }
+ }
+
+ public void delete(String objectKey) {
+ if (objectKey == null || objectKey.isBlank()) return;
+ Path target = resolve(objectKey);
+ try {
+ Files.deleteIfExists(target);
+ Path parent = target.getParent();
+ if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) Files.deleteIfExists(parent);
+ } catch (IOException ignored) { }
+ }
+
+ private Path resolve(String objectKey) {
+ if (objectKey == null || !objectKey.matches("exports/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) {
+ throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key");
+ }
+ Path target = root.resolve(objectKey).normalize();
+ if (!target.startsWith(exportRoot)) throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key");
+ return target;
+ }
+
+ public record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable {
+ @Override public void close() throws IOException { output.close(); }
+ }
+}
diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml
index 132249f7e..56eb0c4a6 100644
--- a/file/src/main/resources/application.yml
+++ b/file/src/main/resources/application.yml
@@ -33,6 +33,10 @@ baiyue:
url: ${IAM_SERVICE_URL:}
file:
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}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql
index 7e35cf92d..aa78343ea 100644
--- a/file/src/main/resources/db/file.sql
+++ b/file/src/main/resources/db/file.sql
@@ -59,3 +59,39 @@ CREATE TABLE IF NOT EXISTS file_download_log (
KEY idx_download_log_file (tenant_id, file_id, downloaded_at),
KEY idx_download_log_user (tenant_id, user_id, downloaded_at)
) ENGINE=InnoDB COMMENT='文件下载审计日志表';
+
+CREATE TABLE IF NOT EXISTS file_export_task (
+ id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0',
+ app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT',
+ task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号',
+ export_type VARCHAR(64) NOT NULL COMMENT '导出类型',
+ format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式',
+ task_name VARCHAR(128) NOT NULL COMMENT '任务名称',
+ parameters_json JSON NOT NULL COMMENT '筛选条件快照',
+ status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCEEDED/FAILED/EXPIRED',
+ total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数',
+ processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数',
+ file_name VARCHAR(255) NULL COMMENT '下载文件名',
+ object_key VARCHAR(512) NULL COMMENT '存储对象键',
+ content_type VARCHAR(128) NULL COMMENT '文件类型',
+ size_bytes BIGINT UNSIGNED NULL COMMENT '文件字节数',
+ failure_message VARCHAR(1000) NULL COMMENT '失败原因',
+ requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请用户ID',
+ requested_username VARCHAR(128) NULL COMMENT '申请账号快照',
+ requested_at DATETIME(3) NOT NULL COMMENT '申请时间',
+ started_at DATETIME(3) NULL COMMENT '开始时间',
+ completed_at DATETIME(3) NULL COMMENT '完成时间',
+ expires_at DATETIME(3) NULL COMMENT '文件过期时间',
+ created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
+ version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
+ PRIMARY KEY (id),
+ 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_expiry (status, expires_at)
+) ENGINE=InnoDB COMMENT='公共异步导出任务表';
diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java
new file mode 100644
index 000000000..cde0a587e
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java
@@ -0,0 +1,23 @@
+package com.baiyue.file.controller;
+
+import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class PlatformExportTaskControllerContractTest {
+ @Test
+ void exposesThePlatformFileRouteAndProtectsListingAndDownloads() throws Exception {
+ RequestMapping mapping = PlatformExportTaskController.class.getAnnotation(RequestMapping.class);
+ assertEquals("/api/platform/v1/files/export-tasks", mapping.value()[0]);
+ assertEquals("isAuthenticated()", PlatformExportTaskController.class
+ .getDeclaredMethod("create", AuthenticatedUser.class, CreateExportTaskRequest.class)
+ .getAnnotation(PreAuthorize.class).value());
+ assertEquals("hasAuthority('platform:download:list')", PlatformExportTaskController.class
+ .getDeclaredMethod("download", AuthenticatedUser.class, long.class)
+ .getAnnotation(PreAuthorize.class).value());
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
index 9d76a85f9..5d5d9413a 100644
--- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
+++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
@@ -159,12 +159,33 @@ class QualificationFileContractTest {
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class,
- () -> controller.upload(merchantUser(), file, null));
+ () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored);
}
+ @Test
+ void merchantUploadForwardsItsQualificationTypeAndCleansTheReplacedFile() {
+ var storage = mock(LocalQualificationStorage.class);
+ var iamClient = mock(IamQualificationClient.class);
+ var controller = merchantController(storage, iamClient);
+ var file = new MockMultipartFile("file", "legal-person.png", "image/png", new byte[] {1, 2, 3});
+ var stored = storedFile();
+ var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png";
+ when(storage.store(10001L, file)).thenReturn(stored);
+ when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
+ new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
+
+ controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true);
+
+ var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class);
+ verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
+ verify(storage).deleteByDocumentUrl(10001L, oldUrl);
+ assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType());
+ assertTrue(requestCaptor.getValue().replace());
+ }
+
private static PlatformQualificationFileController platformController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new PlatformQualificationFileController();
diff --git a/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java b/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java
new file mode 100644
index 000000000..11ce7a8de
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java
@@ -0,0 +1,55 @@
+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());
+ }
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
new file mode 100644
index 000000000..77f54f0d1
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
@@ -0,0 +1,65 @@
+package com.baiyue.file.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
+import com.baiyue.file.entity.FileExportTask;
+import com.baiyue.file.repository.ExportTaskRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+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());
+
+ 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));
+ BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of()),
+ new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of())));
+ assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
+ }
+
+ @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));
+ FileExportTask task = new FileExportTask();
+ task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
+ when(repository.find(9L)).thenReturn(task);
+
+ BizException denied = assertThrows(BizException.class, () -> service.get(user(1L, Set.of()), 9L));
+ assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
+ assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
+ }
+
+ private AuthenticatedUser user(long id, Set permissions) {
+ return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java
new file mode 100644
index 000000000..438ebafdc
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java
@@ -0,0 +1,60 @@
+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);
+ }
+}
diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java
index b0102f75e..823c8d86b 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java
@@ -13,9 +13,14 @@ import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRespon
import com.baiyue.iam.service.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService;
+import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
+import org.springframework.validation.annotation.Validated;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -24,6 +29,7 @@ import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/iam")
+@Validated
public class InternalTenantController {
@Resource private MerchantOnboardingService onboardingService;
@Resource private ProfileResourceService profileResourceService;
@@ -66,6 +72,18 @@ public class InternalTenantController {
return ApiResponse.success(tenantService.orderEligibility(tenantId));
}
+ @GetMapping("/exports/customers")
+ public ApiResponse exportCustomers(
+ @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
+ @RequestParam(required = false) Long cursor,
+ @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false)
+ @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) {
+ verify(token);
+ return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status));
+ }
+
private void verify(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(internalToken, supplied)) {
diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java
index 84491bb47..e3fe3d048 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java
@@ -70,6 +70,13 @@ public class MerchantTenantController {
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
}
+ @PostMapping("/tenant/qualifications/submit-review")
+ @PreAuthorize("hasAuthority('merchant:qualification:upload')")
+ @Operation(summary = "提交当前企业资质审核", description = "校验四类必填资质齐全后创建审核单。上传或替换文件不会自动送审。")
+ public ApiResponse submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
+ return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
+ }
+
@GetMapping("/members")
@Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member list returned / 查询成功")})
diff --git a/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java
new file mode 100644
index 000000000..578e798a7
--- /dev/null
+++ b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java
@@ -0,0 +1,20 @@
+package com.baiyue.iam.dto;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public final class CustomerExportDtos {
+ private CustomerExportDtos() { }
+
+ public record CustomerExportBatch(List records, Long nextCursor, boolean hasMore) { }
+
+ public 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) { }
+}
diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
index 399a1e8e8..1933b48dd 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
@@ -24,12 +24,15 @@ public final class OnboardingDtos {
public record QualificationSubmissionRequest(
@NotNull Long tenantId,
- @NotBlank String qualificationType,
+ @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo,
- @NotBlank String documentUrl
+ @NotBlank String documentUrl,
+ LocalDate validTo,
+ boolean replace
) { }
- public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
+ public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus,
+ String replacedDocumentUrl) { }
public record PlatformQualificationRegistrationRequest(
@NotNull Long tenantId,
diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java
index 7a3401e3b..6633a2d1f 100644
--- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java
+++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java
@@ -31,6 +31,16 @@ public class TenantRepository {
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
+ /**
+ * Serializes the draft qualification workflow for one enterprise. The file service and the
+ * merchant submit action run in separate requests, so qualification-row locks alone cannot
+ * protect the empty-set case.
+ */
+ public IamTenant findTenantForUpdate(Long tenantId) {
+ return tenantMapper.selectOne(new LambdaQueryWrapper()
+ .eq(IamTenant::getId, tenantId)
+ .last("FOR UPDATE"));
+ }
public List listMerchants() {
return tenantMapper.selectList(new LambdaQueryWrapper()
.eq(IamTenant::getTenantType, "MERCHANT")
@@ -71,4 +81,24 @@ public class TenantRepository {
}
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
}
+
+ public List exportMerchants(Long cursor, int limit, String keyword, String status) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(IamTenant::getTenantType, "MERCHANT")
+ .gt(cursor != null, IamTenant::getId, cursor)
+ .eq(StringUtils.hasText(status), IamTenant::getStatus, status)
+ .orderByAsc(IamTenant::getId)
+ .last("LIMIT " + (limit + 1));
+ 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 tenantMapper.selectList(wrapper);
+ }
}
diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
index f27869347..8a146106b 100644
--- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
@@ -54,64 +54,69 @@ public class ProfileResourceService {
}
@Transactional
- @BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION",
- resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false)
+ @BusinessAudit(event = "TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
+ resourceId = "#request.tenantId", description = "上传企业资质", captureAfter = false)
public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) {
- IamTenant tenant = tenantRepository.findTenant(request.tenantId());
+ IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId());
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found");
}
- if ("ACTIVE".equals(tenant.getStatus())) {
- throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved");
+ if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
+ throw new BizException(ResultCode.CONFLICT,
+ "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
}
- if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) {
- throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review");
+ List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
+ .filter(item -> request.qualificationType().equals(item.getQualificationType())
+ && isCurrentQualification(item))
+ .toList();
+ if (!activeQualifications.isEmpty() && !request.replace()) {
+ throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
}
- if (repository.findQualifications(tenant.getId()).stream()
- .anyMatch(item -> !"REJECTED".equals(item.getStatus()))) {
- throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image");
+ String replacedDocumentUrl = null;
+ for (IamTenantQualification existing : activeQualifications) {
+ if (replacedDocumentUrl == null) {
+ replacedDocumentUrl = existing.getDocumentUrl();
+ }
+ existing.setStatus("SUPERSEDED");
+ existing.setReviewComment("商户在送审前替换文件");
+ repository.saveQualification(existing);
}
-
IamTenantQualification qualification = createQualification(tenant.getId(),
request.qualificationType(), request.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());
+ tenantRepository.saveTenant(tenant);
}
- tenant.setStatus("PENDING");
- tenant.setRejectReason(null);
- tenantRepository.saveTenant(tenant);
-
- IamTenantReview review = new IamTenantReview();
- review.setId(IdWorker.getId()); review.setTenantId(tenant.getId());
- review.setReviewType("REGISTER"); review.setStatus("PENDING");
- review.setCreatedAt(LocalDateTime.now());
- tenantRepository.insertReview(review);
- return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus());
+ return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
}
@Transactional
@BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false)
public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
- IamTenant tenant = tenantRepository.findTenant(request.tenantId());
+ IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId());
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
}
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
}
- IamTenantQualification existing = repository.findQualificationsForUpdate(tenant.getId()).stream()
+ List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType())
- && !"REJECTED".equals(item.getStatus()))
- .findFirst().orElse(null);
- if (existing != null && !request.replace()) {
+ && isCurrentQualification(item))
+ .toList();
+ if (!activeQualifications.isEmpty() && !request.replace()) {
throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
}
String replacedDocumentUrl = null;
- if (existing != null) {
- replacedDocumentUrl = existing.getDocumentUrl();
- existing.setStatus("REJECTED");
+ for (IamTenantQualification existing : activeQualifications) {
+ if (replacedDocumentUrl == null) {
+ replacedDocumentUrl = existing.getDocumentUrl();
+ }
+ existing.setStatus("SUPERSEDED");
existing.setReviewComment("平台在送审前替换文件");
repository.saveQualification(existing);
}
@@ -130,6 +135,11 @@ public class ProfileResourceService {
repository.insertQualification(x); return x;
}
+ private boolean isCurrentQualification(IamTenantQualification qualification) {
+ return !"REJECTED".equals(qualification.getStatus())
+ && !"SUPERSEDED".equals(qualification.getStatus());
+ }
+
private void requireMerchant(long tenantId) {
IamTenant tenant = tenantRepository.findTenant(tenantId);
if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId
diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
index 4dac81e29..a467b4b03 100644
--- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.entity.IamTenant;
+import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
@@ -16,6 +17,8 @@ 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.CustomerExportDtos.CustomerExportBatch;
+import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
@@ -23,12 +26,15 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Set;
/**
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
*/
@Service
public class TenantService {
+ private static final Set REQUIRED_QUALIFICATION_TYPES = Set.of(
+ "BUSINESS_LICENSE", "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE");
@Resource
private TenantRepository repository;
@Resource
@@ -84,6 +90,26 @@ public class TenantService {
source.page(), source.size(), source.total(), source.pages());
}
+ public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) {
+ int boundedLimit = Math.min(Math.max(limit, 1), 500);
+ List source = repository.exportMerchants(cursor, boundedLimit, keyword, status);
+ boolean hasMore = source.size() > boundedLimit;
+ List page = hasMore ? source.subList(0, boundedLimit) : source;
+ List rows = page.stream().map(this::exportRow).toList();
+ Long nextCursor = page.isEmpty() ? cursor : page.get(page.size() - 1).getId();
+ return new CustomerExportBatch(rows, nextCursor, hasMore);
+ }
+
+ private CustomerExportRow exportRow(IamTenant tenant) {
+ return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(),
+ tenant.getTenantName(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(),
+ tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(),
+ tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(),
+ tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
+ tenant.getSalesOwnerName(), tenant.getServicePackage(), tenant.getPaymentTermDays(),
+ tenant.getStatus(), tenant.getCreatedAt(), tenant.getApprovedAt());
+ }
+
@BusinessAudit(
event = "TENANT_REVIEW",
resourceType = "TENANT_REVIEW",
@@ -104,9 +130,7 @@ public class TenantService {
if (approve) {
var qualifications = profileRepository.findQualifications(review.getTenantId());
- boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus()));
- boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus()));
- if (!hasLicense || !hasLegalId) {
+ if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.CONFLICT, "Required qualifications are incomplete / 必填资质不完整,不能审核通过");
}
}
@@ -206,7 +230,7 @@ public class TenantService {
@BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId",
description = "平台提交企业客户审核")
public TenantResponse submitMerchantReview(long tenantId) {
- IamTenant tenant = requireMerchant(tenantId);
+ IamTenant tenant = requireMerchantForUpdate(tenantId);
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Only draft or rejected enterprise can be submitted / 仅待完善或已驳回客户可提交审核");
}
@@ -214,10 +238,9 @@ public class TenantService {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
}
var qualifications = profileRepository.findQualifications(tenantId);
- boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()));
- boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()));
- if (!hasLicense || !hasLegalId) {
- throw new BizException(ResultCode.BAD_REQUEST, "Business license and legal-person ID are required / 营业执照和法人身份证为必填资质");
+ if (!hasAllPendingQualifications(qualifications)) {
+ throw new BizException(ResultCode.BAD_REQUEST,
+ "Business license, legal-person ID, freight-forwarder certificate and bank account license are required / 营业执照、法人身份证、货代资质证书和银行开户许可证均为必填资质");
}
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
@@ -250,6 +273,11 @@ public class TenantService {
return tenant.getId() != null && tenant.getId().longValue() == id;
}
+ private boolean hasAllPendingQualifications(List qualifications) {
+ return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream()
+ .anyMatch(item -> type.equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())));
+ }
+
private IamTenant requireMerchant(long tenantId) {
IamTenant tenant = repository.findTenant(tenantId);
if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
@@ -258,6 +286,14 @@ public class TenantService {
return tenant;
}
+ private IamTenant requireMerchantForUpdate(long tenantId) {
+ IamTenant tenant = repository.findTenantForUpdate(tenantId);
+ if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
+ throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在");
+ }
+ return tenant;
+ }
+
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql
index 1f6a8507b..fe20d0941 100644
--- a/iam/src/main/resources/db/phase1-admin-rbac.sql
+++ b/iam/src/main/resources/db/phase1-admin-rbac.sql
@@ -88,10 +88,13 @@ VALUES
(5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0),
(5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0),
(5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0),
+ (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.
(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),
+ (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 0),
(5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0),
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0),
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0),
@@ -198,7 +201,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000002, id, 0 FROM iam_permission
WHERE permission_code IN (
'platform:customer', 'platform:customer:list', 'platform:customer:review',
- 'platform:customer:approve', 'platform:customer:reject'
+ 'platform:customer:approve', 'platform:customer:reject', 'platform:customer:export',
+ 'platform:master-data', 'platform:download:list'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql
index 8f91f3199..93e4551e0 100644
--- a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql
+++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql
@@ -18,6 +18,16 @@ UPDATE iam_permission
SET built_in = 1
WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0;
+INSERT INTO iam_permission
+ (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
+ route_path, sort_no, status, built_in, deleted)
+VALUES
+ (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心',
+ '/downloads', 40, 'ACTIVE', 1, 0)
+ON DUPLICATE KEY UPDATE
+ permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no),
+ status = 'ACTIVE', built_in = 1, deleted = 0;
+
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
sort_no, status, built_in, deleted)
@@ -25,6 +35,7 @@ VALUES
(5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0),
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0),
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0),
+ (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0),
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0),
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
(5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0),
@@ -35,7 +46,8 @@ VALUES
(5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0),
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0),
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0),
- (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0)
+ (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0),
+ (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', 10, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE',
built_in = 1, deleted = 0;
@@ -49,10 +61,24 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role',
'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import',
- 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status'
+ 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status',
+ 'platform:customer:export', 'platform:download:list', 'platform:download:all'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+-- 已有客户运营角色继续具备客户导出与本人下载任务查询能力,但不授予查看全部任务。
+INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id
+FROM iam_role_permission existing_grant
+JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id
+JOIN iam_permission added_permission
+ ON added_permission.tenant_id = 0 AND added_permission.app_code = 'PLATFORM'
+ AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list')
+WHERE existing_permission.tenant_id = 0
+ AND existing_permission.app_code = 'PLATFORM'
+ AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review')
+ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+
-- 详情查看从列表和审核权限中拆分;升级时保留既有运营角色的查询能力。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id
diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml
index 25c597710..6d4313c9f 100644
--- a/iam/src/main/resources/openapi/iam-v1.yaml
+++ b/iam/src/main/resources/openapi/iam-v1.yaml
@@ -464,6 +464,16 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
+ /api/merchant/v1/iam/tenant/qualifications/submit-review:
+ post:
+ tags: [IAM Resources / IAM 资源]
+ summary: Submit enterprise qualifications for review / 提交企业资质审核
+ description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review only after all four required qualification types have been provided. / 上传或替换资质不会自动送审;四类必填资质齐全后由此接口创建审核单。
+ responses:
+ '200': {$ref: '#/components/responses/TenantSuccess'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/roles:
parameters:
- {$ref: '#/components/parameters/App'}
@@ -749,7 +759,7 @@ components:
documentUrl: {type: string}
validFrom: {type: string, format: date}
validTo: {type: string, format: date, nullable: true}
- status: {type: string, enum: [PENDING, APPROVED, REJECTED]}
+ status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]}
reviewComment: {type: string}
RegisterRequest:
type: object
diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java
new file mode 100644
index 000000000..d8a786482
--- /dev/null
+++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java
@@ -0,0 +1,41 @@
+package com.baiyue.iam.controller;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
+import com.baiyue.iam.service.MerchantOnboardingService;
+import com.baiyue.iam.service.ProfileResourceService;
+import com.baiyue.iam.service.TenantService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+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 InternalCustomerExportContractTest {
+ @Test
+ void rejectsMissingInternalTokenAndAcceptsConfiguredToken() {
+ TenantService service = mock(TenantService.class);
+ InternalTenantController controller = new InternalTenantController();
+ ReflectionTestUtils.setField(controller, "onboardingService", mock(MerchantOnboardingService.class));
+ ReflectionTestUtils.setField(controller, "profileResourceService", mock(ProfileResourceService.class));
+ ReflectionTestUtils.setField(controller, "tenantService", service);
+ ReflectionTestUtils.setField(controller, "internalTokenValue", "internal-token");
+ controller.initializeToken();
+
+ BizException denied = assertThrows(BizException.class,
+ () -> controller.exportCustomers(null, null, 100, null, null));
+ assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
+
+ when(service.exportMerchants(null, 100, "柏悦", "ACTIVE"))
+ .thenReturn(new CustomerExportBatch(List.of(), null, false));
+ var response = controller.exportCustomers("internal-token", null, 100, "柏悦", "ACTIVE");
+ assertEquals(ResultCode.SUCCESS.code(), response.code());
+ verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE");
+ }
+}
diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java
index cc6af9acd..9dbf1b24c 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java
@@ -35,6 +35,7 @@ class QualificationUploadContractTest {
assertFalse(pathContract.contains("\n post:"));
assertFalse(yaml.contains(" QualificationRequest:"));
+ assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:"));
}
}
}
diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java
index 9e0ae9496..0b03270da 100644
--- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java
@@ -48,9 +48,11 @@ class CustomerProfileMigrationContractTest {
.contains("CALL regression_assert_table('iam_tenant')")
.contains("CREATE PROCEDURE regression_add_iam_column")
.contains("platform:customer:view")
+ .contains("platform:customer:export")
+ .contains("platform:download:list")
.contains("'IAM客户档案字段' AS check_item")
.contains("9 AS expected_count")
- .contains("44 AS expected_count")
+ .contains("47 AS expected_count")
.contains("MIGRATION_COMPLETED");
assertThat(instructions)
.contains("phase1-platform-master-data-and-permission-management.sql")
diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
new file mode 100644
index 000000000..c68a18c1b
--- /dev/null
+++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
@@ -0,0 +1,46 @@
+package com.baiyue.iam.service;
+
+import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
+import com.baiyue.iam.entity.IamTenant;
+import com.baiyue.iam.repository.ProfileResourceRepository;
+import com.baiyue.iam.repository.TenantRepository;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+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 MerchantQualificationUploadTest {
+
+ @Test
+ void uploadsOneDraftQualificationWithoutCreatingAReview() {
+ TenantRepository tenantRepository = mock(TenantRepository.class);
+ ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
+ ProfileResourceService service = new ProfileResourceService();
+ ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository);
+ ReflectionTestUtils.setField(service, "repository", profileRepository);
+ IamTenant tenant = new IamTenant();
+ tenant.setId(10001L);
+ tenant.setTenantType("MERCHANT");
+ tenant.setStatus("PROFILE_REQUIRED");
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
+ when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
+
+ var response = service.submitQualification(new QualificationSubmissionRequest(
+ 10001L, "LEGAL_PERSON_ID", null,
+ "/api/merchant/v1/files/qualifications/legal-person.png", LocalDate.of(2030, 1, 1), false));
+
+ assertEquals("PROFILE_REQUIRED", response.tenantStatus());
+ assertNull(response.reviewId());
+ verify(profileRepository).insertQualification(any());
+ verify(tenantRepository, never()).insertReview(any());
+ }
+}
diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java
index d08e8909d..e31ec75a6 100644
--- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java
@@ -29,7 +29,7 @@ class PlatformQualificationRegistrationTest {
ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository);
IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
- when(tenantRepository.findTenant(10001L)).thenReturn(tenant);
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
@@ -59,7 +59,7 @@ class PlatformQualificationRegistrationTest {
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID");
existing.setStatus("PENDING");
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
- when(tenantRepository.findTenant(10001L)).thenReturn(tenant);
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
@@ -68,7 +68,7 @@ class PlatformQualificationRegistrationTest {
ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository, times(2)).saveQualification(captor.capture());
- assertEquals("REJECTED", existing.getStatus());
+ assertEquals("SUPERSEDED", existing.getStatus());
assertEquals("平台在送审前替换文件", existing.getReviewComment());
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
assertEquals("PENDING", captor.getAllValues().get(1).getStatus());
diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java
new file mode 100644
index 000000000..2786137be
--- /dev/null
+++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java
@@ -0,0 +1,42 @@
+package com.baiyue.iam.service;
+
+import com.baiyue.iam.entity.IamTenant;
+import com.baiyue.iam.repository.ProfileResourceRepository;
+import com.baiyue.iam.repository.TenantRepository;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TenantCustomerExportTest {
+ @Test
+ void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
+ TenantRepository repository = mock(TenantRepository.class);
+ TenantService service = new TenantService();
+ ReflectionTestUtils.setField(service, "repository", repository);
+ ReflectionTestUtils.setField(service, "profileRepository", mock(ProfileResourceRepository.class));
+ IamTenant tenant = new IamTenant();
+ tenant.setId(101L);
+ tenant.setTenantCode("TEN101");
+ tenant.setTenantName("柏悦物流");
+ tenant.setContactPhone("13800138000");
+ tenant.setContactEmail("ops@example.com");
+ tenant.setStatus("ACTIVE");
+ when(repository.exportMerchants(100L, 50, "柏悦", "ACTIVE")).thenReturn(List.of(tenant));
+
+ var result = service.exportMerchants(100L, 50, "柏悦", "ACTIVE");
+
+ assertEquals("101", result.records().get(0).id());
+ assertEquals("13800138000", result.records().get(0).contactPhone());
+ assertEquals("ops@example.com", result.records().get(0).contactEmail());
+ assertEquals(101L, result.nextCursor());
+ assertFalse(result.hasMore());
+ verify(repository).exportMerchants(100L, 50, "柏悦", "ACTIVE");
+ }
+}
diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
index c3b20b274..1197047b4 100644
--- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
@@ -125,16 +125,17 @@ class TenantServiceTest {
}
@Test
- void explicitSubmissionRequiresBothMandatoryQualificationsAndCreatesReview() {
+ void explicitSubmissionRequiresAllPrototypeQualificationsAndCreatesReview() {
TenantRepository repository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
TenantService service = new TenantService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
- when(repository.findTenant(10001L)).thenReturn(tenant);
+ when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
- qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
+ qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
+ qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
var submitted = service.submitMerchantReview(10001L);
@@ -143,6 +144,23 @@ class TenantServiceTest {
verify(repository).saveTenant(tenant);
}
+ @Test
+ void explicitSubmissionRejectsAnIncompleteQualificationSet() {
+ TenantRepository repository = mock(TenantRepository.class);
+ ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
+ TenantService service = new TenantService();
+ ReflectionTestUtils.setField(service, "repository", repository);
+ ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
+ when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED"));
+ when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
+ qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
+
+ BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
+
+ assertEquals(ResultCode.BAD_REQUEST, exception.getResultCode());
+ verify(repository, never()).insertReview(any());
+ }
+
@Test
void 企业激活不等于具备下单资格仍需已审核资质() {
TenantRepository repository = mock(TenantRepository.class);
From 3afcff320a2c4bee35ef4b4e3a267c1f3483c12c Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 4 Aug 2026 09:05:28 +0800
Subject: [PATCH 2/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 3 +-
.../auth/client/IamOnboardingClient.java | 7 +
.../auth/controller/AuthController.java | 2 +-
.../service/MerchantRegistrationService.java | 83 +-
.../MerchantRegistrationServiceTest.java | 116 ++
.../common/core/export/ExportContracts.java | 28 +
common-web/pom.xml | 4 +
config/nacos/cn/file-service.yml | 13 +
config/nacos/local/file-service.yml | 13 +
deploy/docker-compose.rocketmq.yml | 46 +
deploy/docker-compose.yml | 8 +
deploy/env/.env.infra-test.example | 5 +
deploy/rocketmq/broker.conf.template | 11 +
deploy/rocketmq/render-broker-config.sh | 15 +
.../2026-07-31一期回归测试环境增量迁移.sql | 160 +-
deploy/sql/README.md | 48 +-
docs/公共导出与下载中心接入说明.md | 212 ++-
docs/公共导出中心RocketMQ架构设计.md | 1394 +++++++++++++++++
file/AGENTS.md | 5 +
file/pom.xml | 1 +
.../file/client/ExportProviderClient.java | 47 +
.../client/ExportProviderFeignClient.java | 17 +
.../ExportProviderFeignConfiguration.java | 31 +
...portProviderResponseTooLargeException.java | 7 +
.../client/FirstlegExportProviderClient.java | 6 +
.../baiyue/file/client/IamExportClient.java | 34 -
.../file/client/IamExportProviderClient.java | 6 +
.../client/OrderExportProviderClient.java | 6 +
.../client/WarehouseExportProviderClient.java | 6 +
.../file/config/ExportDefinitionRegistry.java | 78 +
.../PlatformQualificationFileController.java | 1 +
.../QualificationFileController.java | 3 +-
.../file/controller/QualificationTypes.java | 22 +
.../baiyue/file/entity/FileExportOutbox.java | 31 +
.../baiyue/file/entity/FileExportTask.java | 8 +
.../file/mapper/FileExportOutboxMapper.java | 55 +
.../file/mapper/FileExportTaskMapper.java | 132 +-
.../baiyue/file/mq/ExportCommandConsumer.java | 52 +
.../com/baiyue/file/mq/ExportDlqHandler.java | 36 +
.../baiyue/file/mq/ExportOutboxPublisher.java | 54 +
.../repository/ExportOutboxRepository.java | 27 +
.../file/repository/ExportTaskRepository.java | 134 +-
.../service/CustomerExportTaskHandler.java | 168 --
.../file/service/ExportCommandFactory.java | 40 +
.../baiyue/file/service/ExportFailure.java | 10 +
.../baiyue/file/service/ExportMetrics.java | 22 +
.../baiyue/file/service/ExportReconciler.java | 51 +
.../file/service/ExportStateService.java | 98 ++
.../baiyue/file/service/ExportStorage.java | 21 +
.../file/service/ExportTaskExecutor.java | 111 ++
.../file/service/ExportTaskHandler.java | 17 -
.../file/service/ExportTaskService.java | 105 +-
.../baiyue/file/service/ExportTaskWorker.java | 75 -
.../file/service/GenericXlsxExportRunner.java | 208 +++
.../file/service/LocalExportStorage.java | 40 +-
.../service/LocalQualificationStorage.java | 4 +-
.../file/service/SizeLimitedOutputStream.java | 19 +
file/src/main/resources/application.yml | 59 +-
file/src/main/resources/db/file.sql | 43 +
.../file/client/ExportProviderClientTest.java | 33 +
.../ExportProviderFeignConfigurationTest.java | 25 +
.../config/ExportDefinitionRegistryTest.java | 30 +
.../ExportInfrastructureContractTest.java | 57 +
.../QualificationFileContractTest.java | 38 +
.../file/mapper/ExportMapperContractTest.java | 54 +
.../file/mq/ExportCommandConsumerTest.java | 38 +
.../baiyue/file/mq/ExportDlqHandlerTest.java | 29 +
.../file/mq/ExportOutboxPublisherTest.java | 67 +
.../CustomerExportTaskHandlerTest.java | 55 -
.../file/service/ExportReconcilerTest.java | 44 +
.../file/service/ExportStateServiceTest.java | 110 ++
.../file/service/ExportTaskExecutorTest.java | 105 ++
.../file/service/ExportTaskServiceTest.java | 37 +-
.../file/service/ExportTaskWorkerTest.java | 60 -
.../service/GenericXlsxExportRunnerTest.java | 67 +
.../LocalQualificationStorageTest.java | 13 +
.../service/SizeLimitedOutputStreamTest.java | 19 +
firstleg/pom.xml | 2 -
iam/AGENTS.md | 4 +-
iam/README-IAM.md | 8 +
.../CustomerListExportProviderController.java | 47 +
.../controller/InternalTenantController.java | 9 +
.../controller/PlatformTenantController.java | 10 +-
.../java/com/baiyue/iam/dto/TenantDtos.java | 13 +-
.../java/com/baiyue/iam/entity/IamTenant.java | 1 +
.../CustomerListExportProviderRepository.java | 60 +
.../iam/repository/MemberRepository.java | 6 +
.../iam/repository/TenantRepository.java | 11 +
.../CustomerListExportProviderService.java | 265 ++++
.../service/MerchantOnboardingService.java | 57 +-
.../iam/service/ProfileResourceService.java | 35 +-
...enantAdministratorProvisioningService.java | 295 ++++
...nantApprovalInitializationCoordinator.java | 123 ++
.../service/TenantApprovalInitializer.java | 22 +
.../com/baiyue/iam/service/TenantService.java | 88 +-
iam/src/main/resources/db/iam.sql | 1 +
.../main/resources/db/phase1-admin-rbac.sql | 2 +-
.../main/resources/db/phase1-onboarding.sql | 17 +-
iam/src/main/resources/openapi/iam-v1.yaml | 69 +-
...tomerListExportProviderControllerTest.java | 57 +
.../PlatformTenantControllerContractTest.java | 11 +
...tomerListExportProviderRepositoryTest.java | 50 +
.../CustomerProfileMigrationContractTest.java | 32 +-
...CustomerListExportProviderServiceTest.java | 161 ++
.../MerchantOnboardingServiceTest.java | 178 +++
.../MerchantQualificationUploadTest.java | 29 +
...tAdministratorProvisioningServiceTest.java | 242 +++
...ApprovalInitializationCoordinatorTest.java | 95 ++
.../baiyue/iam/service/TenantServiceTest.java | 149 +-
.../resources/db/verify-phase1-admin-rbac.sql | 22 +-
.../service/OrderApplicationService.java | 2 -
.../OrderApplicationServiceStateTest.java | 101 ++
platform-bom/pom.xml | 1 +
pom.xml | 6 +
scripts/export-center-test-env-acceptance.ps1 | 171 ++
scripts/phase1-onboarding-order-e2e.ps1 | 107 +-
116 files changed, 6749 insertions(+), 749 deletions(-)
create mode 100644 auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java
create mode 100644 common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java
create mode 100644 deploy/docker-compose.rocketmq.yml
create mode 100644 deploy/rocketmq/broker.conf.template
create mode 100644 deploy/rocketmq/render-broker-config.sh
create mode 100644 docs/公共导出中心RocketMQ架构设计.md
create mode 100644 file/src/main/java/com/baiyue/file/client/ExportProviderClient.java
create mode 100644 file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java
create mode 100644 file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java
create mode 100644 file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java
create mode 100644 file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java
delete mode 100644 file/src/main/java/com/baiyue/file/client/IamExportClient.java
create mode 100644 file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java
create mode 100644 file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java
create mode 100644 file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java
create mode 100644 file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java
create mode 100644 file/src/main/java/com/baiyue/file/controller/QualificationTypes.java
create mode 100644 file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java
create mode 100644 file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java
create mode 100644 file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java
create mode 100644 file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java
create mode 100644 file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java
create mode 100644 file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java
delete mode 100644 file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportFailure.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportMetrics.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportReconciler.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportStateService.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportStorage.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java
delete mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java
delete mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java
create mode 100644 file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java
create mode 100644 file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java
create mode 100644 file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java
create mode 100644 file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java
create mode 100644 file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java
create mode 100644 file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java
create mode 100644 file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java
create mode 100644 file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java
delete mode 100644 file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java
delete mode 100644 file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java
create mode 100644 file/src/test/java/com/baiyue/file/service/SizeLimitedOutputStreamTest.java
create mode 100644 iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java
create mode 100644 iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java
create mode 100644 iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java
create mode 100644 iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java
create mode 100644 iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java
create mode 100644 iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializer.java
create mode 100644 iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java
create mode 100644 iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java
create mode 100644 scripts/export-center-test-env-acceptance.ps1
diff --git a/AGENTS.md b/AGENTS.md
index 771987cc4..ee0ad0f9e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 -am test`。
+- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
## 通用修改约束
diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java
index 704653bb8..b78cc2138 100644
--- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java
+++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java
@@ -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 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) { }
diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java
index ecee6b279..72c8edf2b 100644
--- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java
+++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java
@@ -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 register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request));
}
diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
index 64287017c..09dd998cd 100644
--- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
+++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
@@ -11,13 +11,21 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import jakarta.annotation.Resource;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.atomic.AtomicBoolean;
@Service
public class MerchantRegistrationService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(MerchantRegistrationService.class);
@Resource private AuthAccountRepository accountRepository;
@Resource private PasswordEncoder passwordEncoder;
@Resource private IamOnboardingClient onboardingClient;
@@ -31,6 +39,9 @@ public class MerchantRegistrationService {
throw new BizException(ResultCode.CONFLICT, "Username already exists");
}
long accountId = IdWorker.getId();
+ String passwordHash = passwordEncoder.encode(request.password());
+ AtomicBoolean compensationCompleted = new AtomicBoolean(false);
+ registerRollbackCompensation(accountId, compensationCompleted);
IamOnboardingClient.OnboardingResponse onboarding;
try {
var response = onboardingClient.register(internalToken,
@@ -51,17 +62,26 @@ public class MerchantRegistrationService {
}
onboarding = response.data();
} catch (FeignException exception) {
- throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
+ BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
+ "IAM registration service is unavailable");
+ failure.addSuppressed(exception);
+ compensateOnboarding(accountId, failure, compensationCompleted);
+ throw failure;
} catch (BizException exception) {
+ compensateOnboarding(accountId, exception, compensationCompleted);
throw exception;
} catch (RuntimeException exception) {
- throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
+ BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
+ "IAM registration service is unavailable");
+ failure.addSuppressed(exception);
+ compensateOnboarding(accountId, failure, compensationCompleted);
+ throw failure;
}
AuthAccount account = new AuthAccount();
account.setId(accountId);
account.setUsername(request.username());
- account.setPasswordHash(passwordEncoder.encode(request.password()));
+ account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE");
account.setFailedLoginCount(0);
@@ -71,8 +91,61 @@ public class MerchantRegistrationService {
account.setDefaultMembershipId(onboarding.membershipId());
account.setDefaultAppCode("MERCHANT");
account.setDeleted(0);
- accountRepository.insert(account);
+ try {
+ accountRepository.insert(account);
+ } catch (DuplicateKeyException exception) {
+ BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
+ failure.addSuppressed(exception);
+ compensateOnboarding(accountId, failure, compensationCompleted);
+ throw failure;
+ } catch (RuntimeException exception) {
+ compensateOnboarding(accountId, exception, compensationCompleted);
+ throw exception;
+ }
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
- onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE");
+ onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
+ }
+
+ private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ return;
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCompletion(int status) {
+ if (status == STATUS_COMMITTED || compensationCompleted.get()) {
+ return;
+ }
+ IllegalStateException rollbackFailure = new IllegalStateException(
+ "Auth transaction rolled back after IAM onboarding");
+ if (!compensateOnboarding(accountId, rollbackFailure, compensationCompleted)) {
+ LOGGER.error("IAM registration compensation failed after Auth rollback, authUserId={}",
+ accountId, rollbackFailure);
+ }
+ }
+ });
+ }
+
+ private boolean compensateOnboarding(long accountId, RuntimeException registrationFailure,
+ AtomicBoolean compensationCompleted) {
+ if (compensationCompleted.get()) {
+ return true;
+ }
+ try {
+ var response = onboardingClient.compensate(internalToken, accountId);
+ if (response != null && response.successful()) {
+ compensationCompleted.set(true);
+ return true;
+ }
+ {
+ String code = response == null ? "empty-response" : response.code();
+ String message = response == null ? "IAM compensation returned no response" : response.message();
+ registrationFailure.addSuppressed(new IllegalStateException(
+ "IAM registration compensation failed: code=" + code + ", message=" + message));
+ }
+ } catch (RuntimeException compensationFailure) {
+ registrationFailure.addSuppressed(compensationFailure);
+ }
+ return false;
}
}
diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java
new file mode 100644
index 000000000..61ce317ec
--- /dev/null
+++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java
@@ -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 onboardingRequest =
+ ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
+ ArgumentCaptor 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 onboardingRequest =
+ ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
+ ArgumentCaptor 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();
+ }
+ }
+}
diff --git a/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java
new file mode 100644
index 000000000..1b6a6cbcc
--- /dev/null
+++ b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java
@@ -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 parameters, String cursor,
+ String snapshotToken, int limit) { }
+
+ public record ExportBatchResponse(
+ int schemaVersion, String schemaHash, String snapshotToken,
+ List columns, List
diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java
new file mode 100644
index 000000000..eaf2752cf
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java
@@ -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 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 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 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();
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java
new file mode 100644
index 000000000..d069633f6
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignClient.java
@@ -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 batch(
+ @RequestHeader("X-Internal-Service-Token") String internalToken,
+ @PathVariable String exportType,
+ @RequestBody ExportBatchRequest request);
+}
diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java
new file mode 100644
index 000000000..b6df5f054
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/ExportProviderFeignConfiguration.java
@@ -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 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();
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java b/file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java
new file mode 100644
index 000000000..18b1a5a91
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/ExportProviderResponseTooLargeException.java
@@ -0,0 +1,7 @@
+package com.baiyue.file.client;
+
+public class ExportProviderResponseTooLargeException extends RuntimeException {
+ public ExportProviderResponseTooLargeException(long limit) {
+ super("Provider 单批响应超过 " + limit + " 字节");
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java
new file mode 100644
index 000000000..e17e61489
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java
@@ -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 { }
diff --git a/file/src/main/java/com/baiyue/file/client/IamExportClient.java b/file/src/main/java/com/baiyue/file/client/IamExportClient.java
deleted file mode 100644
index fc48270c6..000000000
--- a/file/src/main/java/com/baiyue/file/client/IamExportClient.java
+++ /dev/null
@@ -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 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 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) { }
-}
diff --git a/file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java
new file mode 100644
index 000000000..e1af7b346
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java
@@ -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 { }
diff --git a/file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java
new file mode 100644
index 000000000..422d96da9
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java
@@ -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 { }
diff --git a/file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java
new file mode 100644
index 000000000..23dde5644
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java
@@ -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 { }
diff --git a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java
new file mode 100644
index 000000000..3b6bc0713
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java
@@ -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 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 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 getDefinitions() { return definitions; }
+ public void setDefinitions(Map definitions) {
+ Map 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; }
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java
index fc62ba24c..0b574d73a 100644
--- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java
+++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java
@@ -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,
diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
index 02044c6fe..cbda3cdbc 100644
--- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
+++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java
@@ -33,11 +33,12 @@ public class QualificationFileController {
public ApiResponse 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,
diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java
new file mode 100644
index 000000000..6062ca9c6
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java
@@ -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 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;
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java b/file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java
new file mode 100644
index 000000000..9020089a8
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/entity/FileExportOutbox.java
@@ -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;
+}
diff --git a/file/src/main/java/com/baiyue/file/entity/FileExportTask.java b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java
index 23fd60421..d2752731f 100644
--- a/file/src/main/java/com/baiyue/file/entity/FileExportTask.java
+++ b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java
@@ -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;
diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java
new file mode 100644
index 000000000..5ed9e1503
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java
@@ -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 {
+ @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 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 { }
+public interface FileExportTaskMapper extends BaseMapper {
+ @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 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 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 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 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);
+}
diff --git a/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java b/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java
new file mode 100644
index 000000000..315ef023f
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java
@@ -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, 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);
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java b/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java
new file mode 100644
index 000000000..c5a3bfbc5
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java
@@ -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 {
+ 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(); }
+}
diff --git a/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java b/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java
new file mode 100644
index 000000000..1a86faea3
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java
@@ -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; }
+}
diff --git a/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java
new file mode 100644
index 000000000..e731fdfd3
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java
@@ -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 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); }
+}
diff --git a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java
index 0455c953f..dcec05c84 100644
--- a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java
+++ b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java
@@ -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 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 staleRunning() { return mapper.staleRunning(); }
+ public List 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()
- .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()
- .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()
- .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()
- .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()
- .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()
- .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()
- .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 expiredArtifacts(LocalDateTime now) {
- return mapper.selectList(new LambdaQueryWrapper()
- .eq(FileExportTask::getStatus, "SUCCEEDED")
- .le(FileExportTask::getExpiresAt, now)
- .isNotNull(FileExportTask::getObjectKey)
- .last("LIMIT 50"));
- }
-
public void markExpired(long id) {
mapper.update(null, new LambdaUpdateWrapper()
.eq(FileExportTask::getId, id)
diff --git a/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java b/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java
deleted file mode 100644
index da0933569..000000000
--- a/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java
+++ /dev/null
@@ -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 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 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) { }
-}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java b/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java
new file mode 100644
index 000000000..f0e7ae1d7
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java
@@ -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); }
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportFailure.java b/file/src/main/java/com/baiyue/file/service/ExportFailure.java
new file mode 100644
index 000000000..e49b5416b
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportFailure.java
@@ -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); }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportMetrics.java b/file/src/main/java/com/baiyue/file/service/ExportMetrics.java
new file mode 100644
index 000000000..4c6954d84
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportMetrics.java
@@ -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;}}
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportReconciler.java b/file/src/main/java/com/baiyue/file/service/ExportReconciler.java
new file mode 100644
index 000000000..15bc6000b
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportReconciler.java
@@ -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); }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportStateService.java b/file/src/main/java/com/baiyue/file/service/ExportStateService.java
new file mode 100644
index 000000000..8f3c288e8
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportStateService.java
@@ -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) { }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportStorage.java b/file/src/main/java/com/baiyue/file/service/ExportStorage.java
new file mode 100644
index 000000000..ddcf35fa2
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportStorage.java
@@ -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 referenced);
+
+ record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable {
+ @Override public void close() throws IOException { output.close(); }
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java b/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java
new file mode 100644
index 000000000..5790dec11
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java
@@ -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();
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java b/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java
deleted file mode 100644
index ba45ebd25..000000000
--- a/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java
+++ /dev/null
@@ -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 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); }
-}
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
index 15b1c6395..ffa345884 100644
--- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
+++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
@@ -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 handlers;
+ private final ExportDefinitionRegistry registry;
+ private final ExportStateService stateService;
+ private final ExportMetrics metrics;
- public ExportTaskService(ExportTaskRepository repository, LocalExportStorage storage,
- ObjectMapper objectMapper, List 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 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 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 parameters) {
+ Map 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(),
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java b/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java
deleted file mode 100644
index 41168e92a..000000000
--- a/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java
new file mode 100644
index 000000000..b41e9eb63
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java
@@ -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 parameters = parameters(task.getParametersJson());
+ ExportStorage.PendingFile target = storage.create(task.getId());
+ long rows = 0;
+ String cursor = null, snapshot = null, schemaHash = null;
+ List columns = null;
+ Set columnKeys = Set.of();
+ Set warnedUnknownColumns = new HashSet<>();
+ Set 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 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 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 validateColumns(List columns) {
+ if (columns == null || columns.isEmpty() || columns.size() > 100) throw ExportFailure.permanent("Provider 导出列定义无效");
+ Set 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 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 columns, Set columnKeys, Map values,
+ WorkbookStyles styles, Set warnedUnknownColumns, Set warnedTruncatedColumns) {
+ Map 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 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 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 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) { }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java
index 59adb5009..da34ac5a6 100644
--- a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java
+++ b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java
@@ -13,10 +13,14 @@ import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
import java.util.UUID;
@Service
-public class LocalExportStorage {
+public class LocalExportStorage implements ExportStorage {
private final Path root;
private final Path exportRoot;
@@ -61,14 +65,35 @@ public class LocalExportStorage {
catch (Exception exception) { throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); }
}
- public void delete(String objectKey) {
- if (objectKey == null || objectKey.isBlank()) return;
+ public boolean delete(String objectKey) {
+ if (objectKey == null || objectKey.isBlank()) return true;
Path target = resolve(objectKey);
try {
Files.deleteIfExists(target);
- Path parent = target.getParent();
- if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) Files.deleteIfExists(parent);
- } catch (IOException ignored) { }
+ } catch (IOException ignored) {
+ return false;
+ }
+ Path parent = target.getParent();
+ if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) {
+ try { Files.deleteIfExists(parent); }
+ catch (IOException ignored) { /* 文件已经删除,空目录清理失败不影响任务过期。 */ }
+ }
+ return true;
+ }
+
+ @Override
+ public int cleanupOrphans(Duration minimumAge, Predicate referenced) {
+ int removed = 0;
+ Instant cutoff = Instant.now().minus(minimumAge);
+ try (Stream 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(); }
- }
}
diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
index a92add9a1..c25721192 100644
--- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
+++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
@@ -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 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)) {
diff --git a/file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java b/file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java
new file mode 100644
index 000000000..e2049c3ba
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/SizeLimitedOutputStream.java
@@ -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); }
+ }
+}
diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml
index 56eb0c4a6..80bf94bfa 100644
--- a/file/src/main/resources/application.yml
+++ b/file/src/main/resources/application.yml
@@ -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:
diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql
index aa78343ea..1934219a3 100644
--- a/file/src/main/resources/db/file.sql
+++ b/file/src/main/resources/db/file.sql
@@ -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表';
diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java
new file mode 100644
index 000000000..01cb5a1a9
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java
@@ -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);
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java
new file mode 100644
index 000000000..11fbd6567
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java
@@ -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));
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java
new file mode 100644
index 000000000..3fd4e4ffa
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/config/ExportDefinitionRegistryTest.java
@@ -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;
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java
new file mode 100644
index 000000000..fb408ae0a
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java
@@ -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);
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
index 5d5d9413a..950d3ac81 100644
--- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
+++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
@@ -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();
diff --git a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java
new file mode 100644
index 000000000..af8f29d64
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java
@@ -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+"," ");
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java b/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java
new file mode 100644
index 000000000..ae87ae9a6
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java
@@ -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));
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java b/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java
new file mode 100644
index 000000000..573bd7103
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java
@@ -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());
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java b/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java
new file mode 100644
index 000000000..19613f251
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java
@@ -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=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;
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java b/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java
deleted file mode 100644
index 11ce7a8de..000000000
--- a/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java
+++ /dev/null
@@ -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());
- }
- }
-}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java b/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java
new file mode 100644
index 000000000..0f5f4dc19
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java
@@ -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;}
+}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java
new file mode 100644
index 000000000..c1552dbcc
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java
@@ -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;
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java
new file mode 100644
index 000000000..7b7f48511
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java
@@ -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;
+ }
+}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
index 77f54f0d1..5f4a6be31 100644
--- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
+++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
@@ -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 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;
+ }
}
diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java
deleted file mode 100644
index 438ebafdc..000000000
--- a/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java
new file mode 100644
index 000000000..09bf40617
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java
@@ -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 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