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);