From fdb1e8af1c8ad711e9c98caa02f829f40b6779ea Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 3 Aug 2026 18:28:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/scripts/initialize-test-databases.sh | 3 +- .../2026-07-31一期回归测试环境增量迁移.sql | 87 +++++++-- docs/公共导出与下载中心接入说明.md | 161 +++++++++++++++++ docs/头程项目目录结构与服务说明.md | 4 +- file/pom.xml | 1 + .../baiyue/file/FileServiceApplication.java | 2 + .../baiyue/file/client/IamExportClient.java | 34 ++++ .../file/client/IamQualificationClient.java | 6 +- .../PlatformExportTaskController.java | 81 +++++++++ .../QualificationFileController.java | 14 +- .../com/baiyue/file/dto/ExportTaskDtos.java | 24 +++ .../baiyue/file/entity/FileExportTask.java | 37 ++++ .../file/mapper/FileExportTaskMapper.java | 8 + .../file/repository/ExportTaskRepository.java | 132 ++++++++++++++ .../service/CustomerExportTaskHandler.java | 168 ++++++++++++++++++ .../file/service/ExportTaskHandler.java | 17 ++ .../file/service/ExportTaskService.java | 154 ++++++++++++++++ .../baiyue/file/service/ExportTaskWorker.java | 75 ++++++++ .../file/service/LocalExportStorage.java | 86 +++++++++ file/src/main/resources/application.yml | 4 + file/src/main/resources/db/file.sql | 36 ++++ ...tformExportTaskControllerContractTest.java | 23 +++ .../QualificationFileContractTest.java | 23 ++- .../CustomerExportTaskHandlerTest.java | 55 ++++++ .../file/service/ExportTaskServiceTest.java | 65 +++++++ .../file/service/ExportTaskWorkerTest.java | 60 +++++++ .../controller/InternalTenantController.java | 18 ++ .../controller/MerchantTenantController.java | 7 + .../baiyue/iam/dto/CustomerExportDtos.java | 20 +++ .../com/baiyue/iam/dto/OnboardingDtos.java | 9 +- .../iam/repository/TenantRepository.java | 30 ++++ .../iam/service/ProfileResourceService.java | 68 ++++--- .../com/baiyue/iam/service/TenantService.java | 52 +++++- .../main/resources/db/phase1-admin-rbac.sql | 6 +- ...-master-data-and-permission-management.sql | 30 +++- iam/src/main/resources/openapi/iam-v1.yaml | 12 +- .../InternalCustomerExportContractTest.java | 41 +++++ .../QualificationUploadContractTest.java | 1 + .../CustomerProfileMigrationContractTest.java | 4 +- .../MerchantQualificationUploadTest.java | 46 +++++ ...PlatformQualificationRegistrationTest.java | 6 +- .../iam/service/TenantCustomerExportTest.java | 42 +++++ .../baiyue/iam/service/TenantServiceTest.java | 24 ++- 43 files changed, 1707 insertions(+), 69 deletions(-) create mode 100644 docs/公共导出与下载中心接入说明.md create mode 100644 file/src/main/java/com/baiyue/file/client/IamExportClient.java create mode 100644 file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java create mode 100644 file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java create mode 100644 file/src/main/java/com/baiyue/file/entity/FileExportTask.java create mode 100644 file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java create mode 100644 file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java create mode 100644 file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskService.java create mode 100644 file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java create mode 100644 file/src/main/java/com/baiyue/file/service/LocalExportStorage.java create mode 100644 file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java create mode 100644 file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java create mode 100644 file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java create mode 100644 file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java create mode 100644 iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java create mode 100644 iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh index c39da79b0..9e97a52b6 100644 --- a/deploy/scripts/initialize-test-databases.sh +++ b/deploy/scripts/initialize-test-databases.sh @@ -33,7 +33,8 @@ for sql_file in \ warehouse/src/main/resources/db/warehouse-partner.sql \ warehouse/src/main/resources/db/warehouse-express-company.sql \ warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \ - warehouse/src/main/resources/db/warehouse-seed.sql + warehouse/src/main/resources/db/warehouse-seed.sql \ + file/src/main/resources/db/file.sql do if [ ! -f "$sql_file" ]; then echo "找不到初始化 SQL:$sql_file" >&2 diff --git a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql index 0b775bc94..be5de8e48 100644 --- a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql +++ b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql @@ -1,6 +1,6 @@ -- 一期回归测试环境增量迁移汇总 -- 适用版本:MySQL 8.0 --- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg +-- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg、baiyue_file -- 安全边界:不创建数据库、不清表、不删除业务数据、不写入演示租户及演示业务数据。 -- 可重复执行:所有新增字段、表、索引和权限均带存在性判断或幂等写入。 -- 重要说明:MySQL DDL 会隐式提交,本脚本不能作为一个跨库事务整体回滚。 @@ -100,7 +100,8 @@ VALUES (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 1, 0), (5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 1, 0), (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 1, 0), - (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 1, 0) + (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 1, 0), + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), @@ -120,6 +121,7 @@ VALUES (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', NULL, NULL, 15, 'ACTIVE', 1, 0), (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', NULL, NULL, 20, 'ACTIVE', 1, 0), (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 1, 0), (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增内部用户', NULL, NULL, 10, 'ACTIVE', 1, 0), (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑内部用户', NULL, NULL, 20, 'ACTIVE', 1, 0), (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停内部用户', NULL, NULL, 30, 'ACTIVE', 1, 0), @@ -146,7 +148,8 @@ VALUES (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', NULL, NULL, 30, 'ACTIVE', 1, 0), (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 1, 0), (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', NULL, NULL, 30, 'ACTIVE', 1, 0) + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), @@ -203,7 +206,7 @@ SELECT r.id, p.id, 0 AND r.deleted = 0 AND p.permission_code IN ( 'platform:customer', 'platform:customer:list', 'platform:customer:create', - 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', + 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export', 'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role', 'platform:role:list', 'platform:role:create', 'platform:role:grant', @@ -218,7 +221,8 @@ SELECT r.id, p.id, 0 'platform:master-data', 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:counterparty:list', 'platform:counterparty:create', - 'platform:counterparty:edit', 'platform:counterparty:status' + 'platform:counterparty:edit', 'platform:counterparty:status', + 'platform:download:list', 'platform:download:all' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); @@ -237,6 +241,20 @@ SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenan AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 已有客户列表或审核角色获得客户导出及本人下载任务查询,不继承查看全部任务权限。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id + FROM iam_role_permission existing_grant + JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id + JOIN iam_permission added_permission + ON added_permission.tenant_id = 0 + AND added_permission.app_code = 'PLATFORM' + AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list') + WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + -- 已存在的商户超级管理员/企业管理员获得对应租户的角色维护权限。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT r.id, p.id, r.tenant_id @@ -627,18 +645,59 @@ EXECUTE firstleg_task_list_index_stmt; DEALLOCATE PREPARE firstleg_task_list_index_stmt; -- ============================================================================ --- 5. 执行后只读核验 +-- 5. File:公共导出与下载中心任务表 +-- ============================================================================ +USE baiyue_file; + +CREATE TABLE IF NOT EXISTS file_export_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0', + app_code VARCHAR(24) NOT NULL COMMENT '申请端', + task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号', + export_type VARCHAR(64) NOT NULL COMMENT '导出类型', + format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', + task_name VARCHAR(128) NOT NULL COMMENT '任务名称', + parameters_json JSON NOT NULL COMMENT '筛选条件快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', + total_rows BIGINT UNSIGNED NULL, + processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0, + file_name VARCHAR(255) NULL, + object_key VARCHAR(512) NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NULL, + failure_message VARCHAR(1000) NULL, + requested_by BIGINT UNSIGNED NOT NULL, + requested_username VARCHAR(128) NULL, + requested_at DATETIME(3) NOT NULL, + started_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + expires_at DATETIME(3) NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_file_export_task_no (task_no), + KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at), + KEY idx_file_export_status (status, requested_at), + KEY idx_file_export_expiry (status, expires_at) +) ENGINE=InnoDB COMMENT='公共异步导出任务表'; + +-- ============================================================================ +-- 6. 执行后只读核验 -- ============================================================================ SELECT 'IAM权限资源' AS check_item, COUNT(*) AS actual_count, - 44 AS expected_count, - IF(COUNT(*) = 44, '通过', '请检查缺失权限') AS check_result + 47 AS expected_count, + IF(COUNT(*) = 47, '通过', '请检查缺失权限') AS check_result FROM baiyue_iam.iam_permission WHERE deleted = 0 AND permission_code IN ( 'platform:customer', 'platform:customer:list', 'platform:customer:create', - 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', + 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export', 'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role', 'platform:role:list', 'platform:role:create', 'platform:role:grant', @@ -653,7 +712,8 @@ WHERE deleted = 0 'platform:master-data', 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:counterparty:list', 'platform:counterparty:create', - 'platform:counterparty:edit', 'platform:counterparty:status' + 'platform:counterparty:edit', 'platform:counterparty:status', + 'platform:download:list', 'platform:download:all' ); SELECT @@ -705,5 +765,12 @@ WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_fulfillment_task' AND index_name = 'idx_task_tenant_created'; +SELECT + IF(COUNT(*) = 1, '通过', '缺失') AS export_task_table, + 'baiyue_file.file_export_task' AS object_name +FROM information_schema.tables +WHERE table_schema = 'baiyue_file' + AND table_name = 'file_export_task'; + SELECT 'MIGRATION_COMPLETED' AS migration_status, '请确认上方所有核验结果均为通过后再启动服务回归' AS message; diff --git a/docs/公共导出与下载中心接入说明.md b/docs/公共导出与下载中心接入说明.md new file mode 100644 index 000000000..9e4885f1b --- /dev/null +++ b/docs/公共导出与下载中心接入说明.md @@ -0,0 +1,161 @@ +# 公共导出与下载中心接入说明 + +## 1. 目标与边界 + +`file-service` 统一承载异步导出任务、XLSX 文件生成结果、过期清理和下载鉴权。业务服务仍是业务数据唯一事实来源,通过内部、只读、分批接口向 File 提供导出数据。 + +- 浏览器不得拉取全部分页后自行生成 Excel。 +- File 不跨库查询 IAM、Order、Warehouse 等业务数据库。 +- 任务不保存用户 Bearer Token;服务间调用使用 `X-Internal-Service-Token`。 +- 当前首个导出类型为 `CUSTOMER_LIST`,数据由 IAM 提供。 +- 当前存储实现沿用 `FILE_STORAGE_ROOT` 下的共享目录;生产多实例必须挂载同一持久化目录,后续可替换为对象存储。 + +## 2. 调用流程 + +```text +Admin/Client + -> POST /api/platform/v1/files/export-tasks + -> file_export_task(PENDING) + -> File worker 原子领取(RUNNING) + -> 业务服务 internal export API(最多 500 条/批) + -> SXSSFWorkbook 流式生成 XLSX + -> file_export_task(SUCCEEDED) + -> GET /api/platform/v1/files/export-tasks/{id}/download +``` + +任务状态只允许: + +```text +PENDING -> RUNNING -> SUCCEEDED + -> FAILED -> PENDING(手工重试) +SUCCEEDED -> EXPIRED +``` + +服务重启后数据库中的 `PENDING` 会继续处理;超过配置阈值的 `RUNNING` 会重新排队。多个 File 实例通过 `PENDING` 条件更新原子领取,只有一个实例能成功。 + +## 3. 平台公开接口 + +所有接口都经过 Gateway 的现有 `/api/platform/v1/files/**` 路由。 + +### 3.1 创建导出任务 + +`POST /api/platform/v1/files/export-tasks` + +```json +{ + "exportType": "CUSTOMER_LIST", + "format": "XLSX", + "taskName": "客户列表", + "parameters": { + "keyword": "柏悦", + "status": "ACTIVE" + } +} +``` + +创建接口按 `exportType` 校验来源权限。客户导出要求 `platform:customer:export`。筛选参数在创建时形成快照,后续页面修改筛选条件不会改变已提交任务。 + +### 3.2 下载中心列表 + +`GET /api/platform/v1/files/export-tasks?page=1&size=20&exportType=CUSTOMER_LIST&status=SUCCEEDED&requestedFrom=2026-08-01&requestedTo=2026-08-03` + +需要 `platform:download:list`。默认只返回当前登录用户创建的任务;拥有 `platform:download:all` 时可查看同一租户和应用域内所有用户任务。 + +### 3.3 任务详情、下载和重试 + +- `GET /api/platform/v1/files/export-tasks/{id}` +- `GET /api/platform/v1/files/export-tasks/{id}/download` +- `POST /api/platform/v1/files/export-tasks/{id}/retry` + +下载仅允许 `SUCCEEDED` 且未过期的任务。重试仅允许 `FAILED`,并重新校验该导出类型的来源权限。服务端始终校验任务租户、应用域和申请人归属,前端按钮隐藏不能替代鉴权。 + +## 4. IAM 客户数据提供契约 + +File 调用: + +`GET /internal/iam/exports/customers?cursor={lastId}&limit=500&keyword={keyword}&status={status}` + +请求头: + +```http +X-Internal-Service-Token: ${INTERNAL_SERVICE_TOKEN} +``` + +响应: + +```json +{ + "code": "200", + "data": { + "records": [], + "nextCursor": 190000000000000001, + "hasMore": false + } +} +``` + +IAM 按租户 ID 升序游标读取,只查询 `tenant_type=MERCHANT`,并复用客户列表的 `keyword/status` 语义。导出 DTO 与平台列表 DTO 分离,因此不会复用列表中的电话和邮箱脱敏结果。 + +## 5. 新增其他导出类型 + +以订单导出为例: + +1. 数据所属服务提供带内部令牌校验的只读游标接口,不允许 File 直接访问其数据库。 +2. 在 File 增加一个 `ExportTaskHandler` Bean: + - `exportType()` 返回稳定编码,例如 `FIRST_LEG_ORDER_LIST`; + - `requiredPermission()` 返回创建该任务所需的业务权限; + - `export(...)` 分批调用来源服务、流式写入文件并报告进度。 +3. 增加对应 Feign Client 和导出专用 DTO,不复用面向页面的脱敏或分页 DTO。 +4. 添加稳定权限资源、管理员授权和前端任务类型显示。 +5. 增加处理器 XLSX 内容测试、来源接口筛选/游标测试和前端提交测试。 + +不要在公共 Controller 中增加按类型展开的业务查询分支;类型扩展通过 Spring 注入的 `ExportTaskHandler` 注册表完成。 + +## 6. 存储、保留与配置 + +```yaml +baiyue: + file: + storage-root: ${FILE_STORAGE_ROOT} + export: + retention-days: ${FILE_EXPORT_RETENTION_DAYS:7} + worker-delay-ms: ${FILE_EXPORT_WORKER_DELAY_MS:2000} + stale-running-minutes: ${FILE_EXPORT_STALE_RUNNING_MINUTES:30} +``` + +导出文件位于 `${FILE_STORAGE_ROOT}/exports/{taskId}/{uuid}.xlsx`。`object_key` 必须匹配服务端固定格式,任意绝对路径、`..` 或非 XLSX 后缀都会被拒绝。任务过期后先删除文件,再标记 `EXPIRED`。 + +## 7. SQL 与部署顺序 + +新环境: + +1. 执行 `iam/src/main/resources/db/phase1-admin-rbac.sql` 及后续 IAM 权限脚本。 +2. 执行 `file/src/main/resources/db/file.sql` 创建 `file_export_task`。 +3. 部署 IAM,再部署 File,最后部署 Admin 静态资源。 + +现有测试环境可执行 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql`。脚本幂等创建任务表并补齐: + +- `platform:customer:export` +- `platform:download:list` +- `platform:download:all` + +File 启动前必须确认 `FILE_STORAGE_ROOT` 是持久化且可写的共享目录。 + +## 8. 错误与追踪 + +- 创建、列表和重试使用统一 `ApiResponse`,失败记录业务码、`requestId`、`traceId`。 +- 下载接口成功返回文件流;失败仍由统一异常处理返回 JSON 错误。 +- 来源服务不可用或返回非成功业务码时任务进入 `FAILED`,下载中心展示可理解的失败原因。 +- File 日志使用任务 ID 和导出类型关联故障;不得打印内部令牌、Bearer Token 或完整导出数据。 + +## 9. 验收清单 + +- 创建任务后浏览器没有批量调用业务列表,也没有生成 HTML `.xls`。 +- 当前筛选条件原样进入 `parameters_json`。 +- 两个 worker 同时领取时只有一个能将任务从 `PENDING` 更新为 `RUNNING`。 +- XLSX 可被标准办公软件打开,表头和数据列匹配。 +- 普通用户看不到、下载不了其他用户任务;`platform:download:all` 用户可以。 +- `FAILED` 可以重试,其他状态不能重试。 +- 过期任务不能下载,文件被清理。 +- 服务重启后 `PENDING` 继续执行,超时 `RUNNING` 能重新排队。 +- Admin 下载中心覆盖加载、空数据、失败、处理中、完成、失败、过期和窄屏操作状态。 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 498508392..215b125b4 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -83,7 +83,7 @@ baiyue-firstleg/ | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务、操作分配和分配历史;不是订单主数据服务 | | `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox | | `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、仓库地址、服务能力、启停校验,以及往来单位查询;往来单位不单独拆服务 | -| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;当前主要实现企业资质、营业执照上传和预览 | +| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;实现企业资质上传预览及公共异步导出、下载中心 | | `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 | @@ -177,6 +177,8 @@ IAM 以 `tenant_id` 作为核心隔离维度,同时维护两套访问域:商 - `QualificationFileController`:商户端企业资质文件 - `PlatformQualificationFileController`:平台端鉴权查看 +- `PlatformExportTaskController`:平台异步导出任务、下载与失败重试 +- `ExportTaskHandler`:业务导出类型扩展点;接入规则见 `docs/公共导出与下载中心接入说明.md` - `QualificationFileStorage`:存储抽象 - `LocalQualificationStorage`:当前本地磁盘实现 - `IamQualificationClient`:校验企业资质访问权限 diff --git a/file/pom.xml b/file/pom.xml index e9b118423..4f70fe49d 100644 --- a/file/pom.xml +++ b/file/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml file-service + org.apache.poipoi-ooxml5.3.0 com.baiyue.sharedbaiyue-common-audit${project.version} com.baiyue.sharedbaiyue-common-openapi${project.version} com.baomidoumybatis-plus-spring-boot3-starter diff --git a/file/src/main/java/com/baiyue/file/FileServiceApplication.java b/file/src/main/java/com/baiyue/file/FileServiceApplication.java index d4de34b4b..4c3deb716 100644 --- a/file/src/main/java/com/baiyue/file/FileServiceApplication.java +++ b/file/src/main/java/com/baiyue/file/FileServiceApplication.java @@ -3,12 +3,14 @@ package com.baiyue.file; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 文件中心服务启动入口。 */ @SpringBootApplication(scanBasePackages = "com.baiyue") @EnableFeignClients(basePackages = "com.baiyue.file.client") +@EnableScheduling public class FileServiceApplication { public static void main(String[] args) { SpringApplication.run(FileServiceApplication.class, args); diff --git a/file/src/main/java/com/baiyue/file/client/IamExportClient.java b/file/src/main/java/com/baiyue/file/client/IamExportClient.java new file mode 100644 index 000000000..fc48270c6 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/client/IamExportClient.java @@ -0,0 +1,34 @@ +package com.baiyue.file.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@FeignClient(name = "iam-service", contextId = "iamExportClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamExportClient { + @GetMapping("/exports/customers") + ApiResponse customers( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "500") int limit, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status); + + record CustomerExportBatch(List records, Long nextCursor, boolean hasMore) { } + + record CustomerExportRow( + String id, String tenantCode, String tenantName, String legalName, + String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate, + BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, + String contactName, String contactPhone, String contactEmail, String contactWechat, + String salesOwnerName, String servicePackage, Integer paymentTermDays, + String status, LocalDateTime createdAt, LocalDateTime approvedAt) { } +} diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java index 458b8b1b2..4d93ca066 100644 --- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -20,8 +20,10 @@ public interface IamQualificationClient { @RequestBody PlatformQualificationRequest request); record QualificationRequest(Long tenantId, String qualificationType, - String certificateNo, String documentUrl) { } - record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + String certificateNo, String documentUrl, + java.time.LocalDate validTo, boolean replace) { } + record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus, + String replacedDocumentUrl) { } record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus, String replacedDocumentUrl) { } record PlatformQualificationRequest(Long tenantId, String qualificationType, diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java new file mode 100644 index 000000000..21d95baf2 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java @@ -0,0 +1,81 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse; +import com.baiyue.file.service.ExportTaskService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/platform/v1/files/export-tasks") +@Validated +public class PlatformExportTaskController { + private final ExportTaskService service; + + public PlatformExportTaskController(ExportTaskService service) { this.service = service; } + + @PostMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody CreateExportTaskRequest request) { + return ApiResponse.success(service.create(user, request)); + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:download:list')") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size, + @RequestParam(required = false) String exportType, + @RequestParam(required = false) @Pattern(regexp = "PENDING|RUNNING|SUCCEEDED|FAILED|EXPIRED") String status, + @RequestParam(required = false) LocalDate requestedFrom, + @RequestParam(required = false) LocalDate requestedTo) { + return ApiResponse.success(service.page(user, page, size, exportType, status, requestedFrom, requestedTo)); + } + + @GetMapping("/{id:\\d+}") + @PreAuthorize("hasAuthority('platform:download:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id) { + return ApiResponse.success(service.get(user, id)); + } + + @GetMapping("/{id:\\d+}/download") + @PreAuthorize("hasAuthority('platform:download:list')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) { + var download = service.download(user, id); + ContentDisposition disposition = ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build(); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.parseMediaType(download.contentType())) + .body(download.resource()); + } + + @PostMapping("/{id:\\d+}/retry") + @PreAuthorize("hasAuthority('platform:download:list')") + public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id) { + return ApiResponse.success(service.retry(user, id)); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index 7d431b72a..02044c6fe 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -18,6 +18,8 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; + @RestController @RequestMapping("/api/merchant/v1/files/qualifications") public class QualificationFileController { @@ -31,13 +33,16 @@ public class QualificationFileController { public ApiResponse upload( @AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file, - @RequestParam(value = "certificateNo", required = false) String certificateNo) { + @RequestParam(defaultValue = "BUSINESS_LICENSE") String qualificationType, + @RequestParam(value = "certificateNo", required = false) String certificateNo, + @RequestParam(required = false) LocalDate validTo, + @RequestParam(defaultValue = "false") boolean replace) { long tenantId = merchantTenant(user); var stored = storage.store(tenantId, file); try { var response = iamClient.submit(internalToken, - new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE", - certificateNo, stored.documentUrl())); + new IamQualificationClient.QualificationRequest(tenantId, qualificationType, + certificateNo, stored.documentUrl(), validTo, replace)); if (response == null) { throw new IllegalStateException("IAM qualification response is empty"); } @@ -52,6 +57,9 @@ public class QualificationFileController { throw new IllegalStateException("IAM qualification response data is empty"); } var data = response.data(); + if (!stored.documentUrl().equals(data.replacedDocumentUrl())) { + storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl()); + } return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl())); } catch (BizException exception) { diff --git a/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java new file mode 100644 index 000000000..d1079c543 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/dto/ExportTaskDtos.java @@ -0,0 +1,24 @@ +package com.baiyue.file.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.Map; + +public final class ExportTaskDtos { + private ExportTaskDtos() { } + + public record CreateExportTaskRequest( + @NotBlank @Size(max = 64) String exportType, + @NotBlank @Size(max = 16) String format, + @NotBlank @Size(max = 128) String taskName, + Map parameters) { } + + public record ExportTaskResponse( + String id, String taskNo, String exportType, String format, String taskName, + String status, Long totalRows, long processedRows, String fileName, + String contentType, Long sizeBytes, String failureMessage, + String requestedBy, String requestedUsername, LocalDateTime requestedAt, + LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { } +} diff --git a/file/src/main/java/com/baiyue/file/entity/FileExportTask.java b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java new file mode 100644 index 000000000..23fd60421 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/entity/FileExportTask.java @@ -0,0 +1,37 @@ +package com.baiyue.file.entity; + +import com.baiyue.common.core.model.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("file_export_task") +public class FileExportTask extends BaseEntity { + @TableId private Long id; + private Long tenantId; + private String appCode; + private String taskNo; + private String exportType; + private String format; + private String taskName; + private String parametersJson; + private String status; + private Long totalRows; + private Long processedRows; + private String fileName; + private String objectKey; + private String contentType; + private Long sizeBytes; + private String failureMessage; + private Long requestedBy; + private String requestedUsername; + private LocalDateTime requestedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private LocalDateTime expiresAt; +} diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java new file mode 100644 index 000000000..6ac096cf0 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/mapper/FileExportTaskMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.file.mapper; + +import com.baiyue.file.entity.FileExportTask; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FileExportTaskMapper extends BaseMapper { } diff --git a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java new file mode 100644 index 000000000..0455c953f --- /dev/null +++ b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java @@ -0,0 +1,132 @@ +package com.baiyue.file.repository; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.mapper.FileExportTaskMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class ExportTaskRepository { + private final FileExportTaskMapper mapper; + + public ExportTaskRepository(FileExportTaskMapper mapper) { + this.mapper = mapper; + } + + public void insert(FileExportTask task) { mapper.insert(task); } + + public FileExportTask find(long id) { return mapper.selectById(id); } + + public PageResult page(long page, long size, long tenantId, String appCode, + Long requestedBy, String exportType, String status, + LocalDate requestedFrom, LocalDate requestedTo) { + var query = new LambdaQueryWrapper() + .eq(FileExportTask::getTenantId, tenantId) + .eq(FileExportTask::getAppCode, appCode) + .eq(requestedBy != null, FileExportTask::getRequestedBy, requestedBy) + .eq(StringUtils.hasText(exportType), FileExportTask::getExportType, exportType) + .eq(StringUtils.hasText(status), FileExportTask::getStatus, status) + .ge(requestedFrom != null, FileExportTask::getRequestedAt, + requestedFrom == null ? null : requestedFrom.atStartOfDay()) + .lt(requestedTo != null, FileExportTask::getRequestedAt, + requestedTo == null ? null : requestedTo.plusDays(1).atStartOfDay()) + .orderByDesc(FileExportTask::getRequestedAt) + .orderByDesc(FileExportTask::getId); + return PageResult.from(mapper.selectPage(new Page<>(page, size), query)); + } + + public Long firstPendingId() { + FileExportTask task = mapper.selectOne(new LambdaQueryWrapper() + .select(FileExportTask::getId) + .eq(FileExportTask::getStatus, "PENDING") + .orderByAsc(FileExportTask::getRequestedAt) + .orderByAsc(FileExportTask::getId) + .last("LIMIT 1")); + return task == null ? null : task.getId(); + } + + public boolean claim(long id, LocalDateTime now) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "PENDING") + .set(FileExportTask::getStatus, "RUNNING") + .set(FileExportTask::getStartedAt, now) + .set(FileExportTask::getFailureMessage, null)) == 1; + } + + public void progress(long id, long rows) { + mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "RUNNING") + .set(FileExportTask::getProcessedRows, rows)); + } + + public boolean succeed(long id, String fileName, String objectKey, String contentType, + long sizeBytes, long rows, LocalDateTime completedAt, LocalDateTime expiresAt) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "RUNNING") + .set(FileExportTask::getStatus, "SUCCEEDED") + .set(FileExportTask::getFileName, fileName) + .set(FileExportTask::getObjectKey, objectKey) + .set(FileExportTask::getContentType, contentType) + .set(FileExportTask::getSizeBytes, sizeBytes) + .set(FileExportTask::getTotalRows, rows) + .set(FileExportTask::getProcessedRows, rows) + .set(FileExportTask::getCompletedAt, completedAt) + .set(FileExportTask::getExpiresAt, expiresAt)) == 1; + } + + public void fail(long id, String message, LocalDateTime completedAt) { + mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "RUNNING") + .set(FileExportTask::getStatus, "FAILED") + .set(FileExportTask::getFailureMessage, message) + .set(FileExportTask::getCompletedAt, completedAt)); + } + + public boolean retry(long id) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "FAILED") + .set(FileExportTask::getStatus, "PENDING") + .set(FileExportTask::getProcessedRows, 0L) + .set(FileExportTask::getTotalRows, null) + .set(FileExportTask::getFailureMessage, null) + .set(FileExportTask::getStartedAt, null) + .set(FileExportTask::getCompletedAt, null)) == 1; + } + + public int requeueStale(LocalDateTime cutoff) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getStatus, "RUNNING") + .lt(FileExportTask::getUpdatedAt, cutoff) + .set(FileExportTask::getStatus, "PENDING") + .set(FileExportTask::getStartedAt, null) + .set(FileExportTask::getFailureMessage, "Previous worker stopped before completion; task requeued")); + } + + public List expiredArtifacts(LocalDateTime now) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(FileExportTask::getStatus, "SUCCEEDED") + .le(FileExportTask::getExpiresAt, now) + .isNotNull(FileExportTask::getObjectKey) + .last("LIMIT 50")); + } + + public void markExpired(long id) { + mapper.update(null, new LambdaUpdateWrapper() + .eq(FileExportTask::getId, id) + .eq(FileExportTask::getStatus, "SUCCEEDED") + .set(FileExportTask::getStatus, "EXPIRED")); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java b/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java new file mode 100644 index 000000000..da0933569 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/CustomerExportTaskHandler.java @@ -0,0 +1,168 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.client.IamExportClient; +import com.baiyue.file.entity.FileExportTask; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class CustomerExportTaskHandler implements ExportTaskHandler { + static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final List HEADERS = List.of( + "客户ID", "客户编码", "公司名称", "营业执照企业名称", "统一社会信用代码", "企业类型", + "成立日期", "注册资本", "币种", "注册地址", "联系人", "联系电话", "联系邮箱", "联系微信", + "销售人员", "客户套餐", "账期(天)", "状态", "入驻时间", "审批时间"); + + private final IamExportClient iamClient; + private final LocalExportStorage storage; + private final ObjectMapper objectMapper; + private final String internalToken; + + public CustomerExportTaskHandler(IamExportClient iamClient, LocalExportStorage storage, + ObjectMapper objectMapper, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.iamClient = iamClient; + this.storage = storage; + this.objectMapper = objectMapper; + this.internalToken = internalToken; + } + + @Override public String exportType() { return "CUSTOMER_LIST"; } + @Override public String requiredPermission() { return "platform:customer:export"; } + + @Override + public void validateParameters(Map parameters) { + if (parameters == null) return; + Object keyword = parameters.get("keyword"); + if (keyword != null && String.valueOf(keyword).length() > 128) { + throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出关键词不能超过 128 个字符"); + } + Object status = parameters.get("status"); + if (status != null && !String.valueOf(status).isBlank() + && !Set.of("PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED") + .contains(String.valueOf(status))) { + throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出状态无效"); + } + } + + @Override + public ExportResult export(FileExportTask task, ProgressReporter progress) { + CustomerParameters parameters = parameters(task.getParametersJson()); + LocalExportStorage.PendingFile target = storage.create(task.getId()); + long rows = 0; + try (target; SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { + workbook.setCompressTempFiles(true); + Sheet sheet = workbook.createSheet("客户列表"); + writeHeader(workbook, sheet); + Long cursor = null; + boolean hasMore; + do { + var response = iamClient.customers(internalToken, cursor, 500, + blankToNull(parameters.keyword()), blankToNull(parameters.status())); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM customer export is unavailable"); + } + var batch = response.data(); + for (var item : batch.records()) writeRow(sheet.createRow(Math.toIntExact(++rows)), item); + progress.update(rows); + hasMore = batch.hasMore(); + if (hasMore && (batch.nextCursor() == null || batch.nextCursor().equals(cursor))) { + throw new IllegalStateException("IAM customer export cursor did not advance"); + } + cursor = batch.nextCursor(); + } while (hasMore); + for (int index = 0; index < HEADERS.size(); index++) sheet.setColumnWidth(index, columnWidth(index)); + workbook.write(target.output()); + } catch (IOException exception) { + storage.delete(target.objectKey()); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户导出文件生成失败"); + } catch (RuntimeException exception) { + storage.delete(target.objectKey()); + throw exception; + } + String fileName = "客户列表-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx"; + return new ExportResult(fileName, target.objectKey(), CONTENT_TYPE, + storage.size(target.objectKey()), rows); + } + + private CustomerParameters parameters(String json) { + try { return objectMapper.readValue(json, CustomerParameters.class); } + catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "客户导出筛选条件无效"); } + } + + private void writeHeader(Workbook workbook, Sheet sheet) { + CellStyle style = workbook.createCellStyle(); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + var font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + Row row = sheet.createRow(0); + for (int index = 0; index < HEADERS.size(); index++) { + var cell = row.createCell(index); + cell.setCellValue(HEADERS.get(index)); + cell.setCellStyle(style); + } + sheet.createFreezePane(0, 1); + } + + private void writeRow(Row row, IamExportClient.CustomerExportRow item) { + int column = 0; + cell(row, column++, item.id()); cell(row, column++, item.tenantCode()); + cell(row, column++, item.tenantName()); cell(row, column++, item.legalName()); + cell(row, column++, item.unifiedSocialCreditCode()); cell(row, column++, item.enterpriseType()); + cell(row, column++, item.establishedDate()); cell(row, column++, item.registeredCapital()); + cell(row, column++, item.registeredCapitalCurrency()); cell(row, column++, item.registeredAddress()); + cell(row, column++, item.contactName()); cell(row, column++, item.contactPhone()); + cell(row, column++, item.contactEmail()); cell(row, column++, item.contactWechat()); + cell(row, column++, item.salesOwnerName()); cell(row, column++, item.servicePackage()); + cell(row, column++, item.paymentTermDays()); cell(row, column++, statusLabel(item.status())); + cell(row, column++, item.createdAt()); cell(row, column, item.approvedAt()); + } + + private void cell(Row row, int column, Object value) { + row.createCell(column).setCellValue(value == null ? "" : String.valueOf(value)); + } + + private int columnWidth(int index) { + return switch (index) { + case 2, 3, 9 -> 28 * 256; + case 4, 12 -> 24 * 256; + case 18, 19 -> 21 * 256; + default -> 16 * 256; + }; + } + + private String statusLabel(String status) { + if (status == null) return ""; + return switch (status) { + case "PROFILE_REQUIRED" -> "待完善"; + case "PENDING" -> "待审核"; + case "ACTIVE" -> "启用"; + case "REJECTED" -> "已驳回"; + case "DISABLED" -> "已停用"; + default -> status; + }; + } + + private String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + public record CustomerParameters(String keyword, String status) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java b/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java new file mode 100644 index 000000000..ba45ebd25 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskHandler.java @@ -0,0 +1,17 @@ +package com.baiyue.file.service; + +import com.baiyue.file.entity.FileExportTask; + +import java.util.Map; + +public interface ExportTaskHandler { + String exportType(); + String requiredPermission(); + default void validateParameters(Map parameters) { } + ExportResult export(FileExportTask task, ProgressReporter progress); + + record ExportResult(String fileName, String objectKey, String contentType, long sizeBytes, long rows) { } + + @FunctionalInterface + interface ProgressReporter { void update(long rows); } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java new file mode 100644 index 000000000..15b1c6395 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -0,0 +1,154 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class ExportTaskService { + private final ExportTaskRepository repository; + private final LocalExportStorage storage; + private final ObjectMapper objectMapper; + private final Map handlers; + + public ExportTaskService(ExportTaskRepository repository, LocalExportStorage storage, + ObjectMapper objectMapper, List handlers) { + this.repository = repository; + this.storage = storage; + this.objectMapper = objectMapper; + this.handlers = handlers.stream().collect(Collectors.toUnmodifiableMap( + handler -> handler.exportType().toUpperCase(Locale.ROOT), Function.identity())); + } + + public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) { + requirePlatform(user); + String type = request.exportType().trim().toUpperCase(Locale.ROOT); + String format = request.format().trim().toUpperCase(Locale.ROOT); + ExportTaskHandler handler = handler(type); + if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式"); + if (!user.permissions().contains(handler.requiredPermission())) { + throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); + } + handler.validateParameters(request.parameters() == null ? Map.of() : request.parameters()); + long id = IdWorker.getId(); + LocalDateTime now = LocalDateTime.now(); + FileExportTask task = new FileExportTask(); + task.setId(id); + task.setTenantId(user.tenantId()); + task.setAppCode(user.appCode().toUpperCase(Locale.ROOT)); + task.setTaskNo("EXP" + id); + task.setExportType(type); + task.setFormat(format); + task.setTaskName(request.taskName().trim()); + task.setParametersJson(parametersJson(request.parameters())); + task.setStatus("PENDING"); + task.setProcessedRows(0L); + task.setRequestedBy(user.userId()); + task.setRequestedUsername(user.username()); + task.setRequestedAt(now); + repository.insert(task); + return response(task); + } + + public PageResult page(AuthenticatedUser user, long page, long size, + String exportType, String status, + LocalDate requestedFrom, LocalDate requestedTo) { + requirePlatform(user); + if (requestedFrom != null && requestedTo != null && requestedFrom.isAfter(requestedTo)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "开始日期不能晚于结束日期"); + } + Long owner = user.permissions().contains("platform:download:all") ? null : user.userId(); + PageResult source = repository.page(page, size, user.tenantId(), user.appCode(), owner, + normalize(exportType), normalize(status), requestedFrom, requestedTo); + return new PageResult<>(source.records().stream().map(this::response).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + + public ExportTaskResponse get(AuthenticatedUser user, long id) { + return response(ownedTask(user, id)); + } + + public DownloadResource download(AuthenticatedUser user, long id) { + FileExportTask task = ownedTask(user, id); + if (!"SUCCEEDED".equals(task.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期"); + } + if (task.getExpiresAt() != null && !task.getExpiresAt().isAfter(LocalDateTime.now())) { + throw new BizException(ResultCode.CONFLICT, "导出文件已过期"); + } + return new DownloadResource(storage.load(task.getObjectKey()), task.getFileName(), task.getContentType()); + } + + public ExportTaskResponse retry(AuthenticatedUser user, long id) { + FileExportTask task = ownedTask(user, id); + ExportTaskHandler handler = handler(task.getExportType()); + if (!user.permissions().contains(handler.requiredPermission())) { + throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); + } + if (!"FAILED".equals(task.getStatus()) || !repository.retry(id)) { + throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试"); + } + return response(repository.find(id)); + } + + ExportTaskHandler handler(String exportType) { + ExportTaskHandler handler = handlers.get(exportType == null ? "" : exportType.toUpperCase(Locale.ROOT)); + if (handler == null) throw new BizException(ResultCode.VALIDATION_FAILED, "不支持的导出类型"); + return handler; + } + + private FileExportTask ownedTask(AuthenticatedUser user, long id) { + requirePlatform(user); + FileExportTask task = repository.find(id); + if (task == null || !task.getTenantId().equals(user.tenantId()) + || !task.getAppCode().equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.NOT_FOUND, "导出任务不存在"); + } + if (!task.getRequestedBy().equals(user.userId()) && !user.permissions().contains("platform:download:all")) { + throw new BizException(ResultCode.FORBIDDEN, "不能访问其他用户的导出任务"); + } + return task; + } + + private String parametersJson(Map parameters) { + try { return objectMapper.writeValueAsString(parameters == null ? Map.of() : parameters); } + catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); } + } + + private ExportTaskResponse response(FileExportTask task) { + return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(), + task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(), + task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(), + task.getContentType(), task.getSizeBytes(), task.getFailureMessage(), + String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(), + task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt()); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null + || !"PLATFORM".equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); + } + } + + private String normalize(String value) { return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT); } + + public record DownloadResource(org.springframework.core.io.Resource resource, String fileName, String contentType) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java b/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java new file mode 100644 index 000000000..41168e92a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskWorker.java @@ -0,0 +1,75 @@ +package com.baiyue.file.service; + +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import com.baiyue.common.core.exception.BizException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +public class ExportTaskWorker { + private static final Logger LOGGER = LoggerFactory.getLogger(ExportTaskWorker.class); + private final ExportTaskRepository repository; + private final ExportTaskService service; + private final LocalExportStorage storage; + private final long retentionDays; + private final long staleRunningMinutes; + + public ExportTaskWorker(ExportTaskRepository repository, ExportTaskService service, + LocalExportStorage storage, + @Value("${baiyue.file.export.retention-days:7}") long retentionDays, + @Value("${baiyue.file.export.stale-running-minutes:30}") long staleRunningMinutes) { + this.repository = repository; + this.service = service; + this.storage = storage; + this.retentionDays = Math.max(1, retentionDays); + this.staleRunningMinutes = Math.max(5, staleRunningMinutes); + } + + @Scheduled(fixedDelayString = "${baiyue.file.export.worker-delay-ms:2000}") + public void poll() { + LocalDateTime now = LocalDateTime.now(); + repository.requeueStale(now.minusMinutes(staleRunningMinutes)); + expire(now); + processNext(); + } + + public boolean processNext() { + Long id = repository.firstPendingId(); + if (id == null || !repository.claim(id, LocalDateTime.now())) return false; + FileExportTask task = repository.find(id); + try { + ExportTaskHandler handler = service.handler(task.getExportType()); + var result = handler.export(task, rows -> repository.progress(id, rows)); + LocalDateTime completed = LocalDateTime.now(); + if (!repository.succeed(id, result.fileName(), result.objectKey(), result.contentType(), + result.sizeBytes(), result.rows(), completed, completed.plusDays(retentionDays))) { + storage.delete(result.objectKey()); + throw new IllegalStateException("Export task state changed before completion"); + } + } catch (Exception exception) { + String message = safeMessage(exception); + repository.fail(id, message, LocalDateTime.now()); + LOGGER.error("导出任务执行失败,taskId={},exportType={}", id, task.getExportType(), exception); + } + return true; + } + + private void expire(LocalDateTime now) { + for (FileExportTask task : repository.expiredArtifacts(now)) { + storage.delete(task.getObjectKey()); + repository.markExpired(task.getId()); + } + } + + private String safeMessage(Exception exception) { + String message = exception instanceof BizException ? exception.getMessage() : "导出任务执行失败,请稍后重试"; + if (message == null || message.isBlank()) message = "导出任务执行失败"; + return message.length() <= 1000 ? message : message.substring(0, 1000); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java new file mode 100644 index 000000000..59adb5009 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java @@ -0,0 +1,86 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.UUID; + +@Service +public class LocalExportStorage { + private final Path root; + private final Path exportRoot; + + public LocalExportStorage(@Value("${baiyue.file.storage-root}") String root) { + this.root = Path.of(root).toAbsolutePath().normalize(); + this.exportRoot = this.root.resolve("exports").normalize(); + } + + @PostConstruct + void initialize() { + try { + Files.createDirectories(exportRoot); + if (!exportRoot.startsWith(root) || !Files.isDirectory(exportRoot) || !Files.isWritable(exportRoot)) { + throw new IOException("Export storage is not writable"); + } + } catch (IOException exception) { + throw new IllegalStateException("导出文件存储目录不可用", exception); + } + } + + public PendingFile create(long taskId) { + String objectKey = "exports/" + taskId + "/" + UUID.randomUUID() + ".xlsx"; + Path target = resolve(objectKey); + try { + Files.createDirectories(target.getParent()); + OutputStream output = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + return new PendingFile(objectKey, target, output); + } catch (IOException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件存储暂不可用"); + } + } + + public long size(String objectKey) { + try { return Files.size(resolve(objectKey)); } + catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件读取失败"); } + } + + public Resource load(String objectKey) { + Path target = resolve(objectKey); + if (!Files.isRegularFile(target)) throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); + try { return new UrlResource(target.toUri()); } + catch (Exception exception) { throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); } + } + + public void delete(String objectKey) { + if (objectKey == null || objectKey.isBlank()) return; + Path target = resolve(objectKey); + try { + Files.deleteIfExists(target); + Path parent = target.getParent(); + if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) Files.deleteIfExists(parent); + } catch (IOException ignored) { } + } + + private Path resolve(String objectKey) { + if (objectKey == null || !objectKey.matches("exports/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) { + throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key"); + } + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(exportRoot)) throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key"); + return target; + } + + public record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable { + @Override public void close() throws IOException { output.close(); } + } +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 132249f7e..56eb0c4a6 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -33,6 +33,10 @@ baiyue: url: ${IAM_SERVICE_URL:} file: storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} + export: + retention-days: ${FILE_EXPORT_RETENTION_DAYS:7} + worker-delay-ms: ${FILE_EXPORT_WORKER_DELAY_MS:2000} + stale-running-minutes: ${FILE_EXPORT_STALE_RUNNING_MINUTES:30} management.endpoints.web.exposure.include: health,info,prometheus logging: diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 7e35cf92d..aa78343ea 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -59,3 +59,39 @@ CREATE TABLE IF NOT EXISTS file_download_log ( KEY idx_download_log_file (tenant_id, file_id, downloaded_at), KEY idx_download_log_user (tenant_id, user_id, downloaded_at) ) ENGINE=InnoDB COMMENT='文件下载审计日志表'; + +CREATE TABLE IF NOT EXISTS file_export_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0', + app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT', + task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号', + export_type VARCHAR(64) NOT NULL COMMENT '导出类型', + format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', + task_name VARCHAR(128) NOT NULL COMMENT '任务名称', + parameters_json JSON NOT NULL COMMENT '筛选条件快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCEEDED/FAILED/EXPIRED', + total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数', + processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数', + file_name VARCHAR(255) NULL COMMENT '下载文件名', + object_key VARCHAR(512) NULL COMMENT '存储对象键', + content_type VARCHAR(128) NULL COMMENT '文件类型', + size_bytes BIGINT UNSIGNED NULL COMMENT '文件字节数', + failure_message VARCHAR(1000) NULL COMMENT '失败原因', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请用户ID', + requested_username VARCHAR(128) NULL COMMENT '申请账号快照', + requested_at DATETIME(3) NOT NULL COMMENT '申请时间', + started_at DATETIME(3) NULL COMMENT '开始时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + expires_at DATETIME(3) NULL COMMENT '文件过期时间', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_file_export_task_no (task_no), + KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at), + KEY idx_file_export_status (status, requested_at), + KEY idx_file_export_expiry (status, expires_at) +) ENGINE=InnoDB COMMENT='公共异步导出任务表'; diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java new file mode 100644 index 000000000..cde0a587e --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/PlatformExportTaskControllerContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlatformExportTaskControllerContractTest { + @Test + void exposesThePlatformFileRouteAndProtectsListingAndDownloads() throws Exception { + RequestMapping mapping = PlatformExportTaskController.class.getAnnotation(RequestMapping.class); + assertEquals("/api/platform/v1/files/export-tasks", mapping.value()[0]); + assertEquals("isAuthenticated()", PlatformExportTaskController.class + .getDeclaredMethod("create", AuthenticatedUser.class, CreateExportTaskRequest.class) + .getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:download:list')", PlatformExportTaskController.class + .getDeclaredMethod("download", AuthenticatedUser.class, long.class) + .getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 9d76a85f9..5d5d9413a 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -159,12 +159,33 @@ class QualificationFileContractTest { when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); var exception = assertThrows(BizException.class, - () -> controller.upload(merchantUser(), file, null)); + () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); verify(storage, never()).delete(stored); } + @Test + void merchantUploadForwardsItsQualificationTypeAndCleansTheReplacedFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "legal-person.png", "image/png", new byte[] {1, 2, 3}); + var stored = storedFile(); + var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png"; + when(storage.store(10001L, file)).thenReturn(stored); + when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); + + controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true); + + var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class); + verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); + verify(storage).deleteByDocumentUrl(10001L, oldUrl); + assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType()); + assertTrue(requestCaptor.getValue().replace()); + } + private static PlatformQualificationFileController platformController( LocalQualificationStorage storage, IamQualificationClient iamClient) { var controller = new PlatformQualificationFileController(); diff --git a/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java b/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java new file mode 100644 index 000000000..11ce7a8de --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/CustomerExportTaskHandlerTest.java @@ -0,0 +1,55 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.file.client.IamExportClient; +import com.baiyue.file.entity.FileExportTask; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomerExportTaskHandlerTest { + @TempDir Path tempDir; + + @Test + void generatesARealXlsxFromIamBatchesAndReportsProgress() throws Exception { + IamExportClient client = mock(IamExportClient.class); + LocalExportStorage storage = new LocalExportStorage(tempDir.toString()); + storage.initialize(); + var row = new IamExportClient.CustomerExportRow( + "101", "TEN101", "柏悦物流", "柏悦物流有限公司", "91440300MA5ABCDE12", + "有限责任公司", null, null, "CNY", "深圳市", "张三", "13800138000", + "ops@example.com", "baiyue", "李四", "基础版", 30, "ACTIVE", null, null); + when(client.customers("internal-token", null, 500, "柏悦", "ACTIVE")) + .thenReturn(ApiResponse.success(new IamExportClient.CustomerExportBatch(List.of(row), 101L, false))); + CustomerExportTaskHandler handler = new CustomerExportTaskHandler( + client, storage, new ObjectMapper().findAndRegisterModules(), "internal-token"); + FileExportTask task = new FileExportTask(); + task.setId(9L); + task.setParametersJson("{\"keyword\":\"柏悦\",\"status\":\"ACTIVE\"}"); + AtomicLong progress = new AtomicLong(); + + var result = handler.export(task, progress::set); + + assertEquals(1L, result.rows()); + assertEquals(1L, progress.get()); + assertTrue(result.fileName().endsWith(".xlsx")); + Path workbookPath = tempDir.resolve(result.objectKey()); + assertTrue(Files.isRegularFile(workbookPath)); + try (XSSFWorkbook workbook = new XSSFWorkbook(Files.newInputStream(workbookPath))) { + assertEquals("客户ID", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue()); + assertEquals("柏悦物流", workbook.getSheetAt(0).getRow(1).getCell(2).getStringCellValue()); + assertEquals("13800138000", workbook.getSheetAt(0).getRow(1).getCell(11).getStringCellValue()); + } + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java new file mode 100644 index 000000000..77f54f0d1 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -0,0 +1,65 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest; +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ExportTaskServiceTest { + @Test + void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() { + ExportTaskRepository repository = mock(ExportTaskRepository.class); + ExportTaskHandler handler = mock(ExportTaskHandler.class); + when(handler.exportType()).thenReturn("CUSTOMER_LIST"); + when(handler.requiredPermission()).thenReturn("platform:customer:export"); + ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class), + new ObjectMapper(), List.of(handler)); + doAnswer(invocation -> null).when(repository).insert(any()); + + var response = service.create(user(1L, Set.of("platform:customer:export")), + new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("status", "ACTIVE"))); + + assertEquals("PENDING", response.status()); + assertEquals("CUSTOMER_LIST", response.exportType()); + verify(repository).insert(any(FileExportTask.class)); + BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of()), + new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of()))); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + } + + @Test + void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() { + ExportTaskRepository repository = mock(ExportTaskRepository.class); + ExportTaskHandler handler = mock(ExportTaskHandler.class); + when(handler.exportType()).thenReturn("CUSTOMER_LIST"); + ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class), + new ObjectMapper(), List.of(handler)); + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L); + when(repository.find(9L)).thenReturn(task); + + BizException denied = assertThrows(BizException.class, () -> service.get(user(1L, Set.of()), 9L)); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id()); + } + + private AuthenticatedUser user(long id, Set permissions) { + return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java new file mode 100644 index 000000000..438ebafdc --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskWorkerTest.java @@ -0,0 +1,60 @@ +package com.baiyue.file.service; + +import com.baiyue.file.entity.FileExportTask; +import com.baiyue.file.repository.ExportTaskRepository; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ExportTaskWorkerTest { + @Test + void onlyExecutesATaskAfterTheAtomicClaimSucceeds() { + ExportTaskRepository repository = mock(ExportTaskRepository.class); + ExportTaskService service = mock(ExportTaskService.class); + LocalExportStorage storage = mock(LocalExportStorage.class); + ExportTaskHandler handler = mock(ExportTaskHandler.class); + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setExportType("CUSTOMER_LIST"); + when(repository.firstPendingId()).thenReturn(9L); + when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(true); + when(repository.find(9L)).thenReturn(task); + when(service.handler("CUSTOMER_LIST")).thenReturn(handler); + when(handler.export(eq(task), any())).thenAnswer(invocation -> { + ExportTaskHandler.ProgressReporter progress = invocation.getArgument(1); + progress.update(12L); + return new ExportTaskHandler.ExportResult("客户列表.xlsx", "exports/9/file.xlsx", + CustomerExportTaskHandler.CONTENT_TYPE, 1024L, 12L); + }); + when(repository.succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"), + eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L), + any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(true); + ExportTaskWorker worker = new ExportTaskWorker(repository, service, storage, 7, 30); + + worker.processNext(); + + verify(repository).progress(9L, 12L); + verify(repository).succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"), + eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L), + any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + void skipsAContendedTaskThatAnotherWorkerClaimed() { + ExportTaskRepository repository = mock(ExportTaskRepository.class); + when(repository.firstPendingId()).thenReturn(9L); + when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(false); + ExportTaskWorker worker = new ExportTaskWorker(repository, mock(ExportTaskService.class), + mock(LocalExportStorage.class), 7, 30); + + worker.processNext(); + + verify(repository, never()).find(9L); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index b0102f75e..823c8d86b 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -13,9 +13,14 @@ import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRespon import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import org.springframework.validation.annotation.Validated; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -24,6 +29,7 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/iam") +@Validated public class InternalTenantController { @Resource private MerchantOnboardingService onboardingService; @Resource private ProfileResourceService profileResourceService; @@ -66,6 +72,18 @@ public class InternalTenantController { return ApiResponse.success(tenantService.orderEligibility(tenantId)); } + @GetMapping("/exports/customers") + public ApiResponse exportCustomers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) { + verify(token); + return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status)); + } + private void verify(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(internalToken, supplied)) { diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index 84491bb47..e3fe3d048 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -70,6 +70,13 @@ public class MerchantTenantController { return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request)); } + @PostMapping("/tenant/qualifications/submit-review") + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + @Operation(summary = "提交当前企业资质审核", description = "校验四类必填资质齐全后创建审核单。上传或替换文件不会自动送审。") + public ApiResponse submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT"))); + } + @GetMapping("/members") @Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member list returned / 查询成功")}) diff --git a/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java new file mode 100644 index 000000000..578e798a7 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/CustomerExportDtos.java @@ -0,0 +1,20 @@ +package com.baiyue.iam.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class CustomerExportDtos { + private CustomerExportDtos() { } + + public record CustomerExportBatch(List records, Long nextCursor, boolean hasMore) { } + + public record CustomerExportRow( + String id, String tenantCode, String tenantName, String legalName, + String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate, + BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, + String contactName, String contactPhone, String contactEmail, String contactWechat, + String salesOwnerName, String servicePackage, Integer paymentTermDays, + String status, LocalDateTime createdAt, LocalDateTime approvedAt) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index 399a1e8e8..1933b48dd 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -24,12 +24,15 @@ public final class OnboardingDtos { public record QualificationSubmissionRequest( @NotNull Long tenantId, - @NotBlank String qualificationType, + @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, String certificateNo, - @NotBlank String documentUrl + @NotBlank String documentUrl, + LocalDate validTo, + boolean replace ) { } - public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus, + String replacedDocumentUrl) { } public record PlatformQualificationRegistrationRequest( @NotNull Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index 7a3401e3b..6633a2d1f 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -31,6 +31,16 @@ public class TenantRepository { } public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } + /** + * Serializes the draft qualification workflow for one enterprise. The file service and the + * merchant submit action run in separate requests, so qualification-row locks alone cannot + * protect the empty-set case. + */ + public IamTenant findTenantForUpdate(Long tenantId) { + return tenantMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenant::getId, tenantId) + .last("FOR UPDATE")); + } public List listMerchants() { return tenantMapper.selectList(new LambdaQueryWrapper() .eq(IamTenant::getTenantType, "MERCHANT") @@ -71,4 +81,24 @@ public class TenantRepository { } return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); } + + public List exportMerchants(Long cursor, int limit, String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .gt(cursor != null, IamTenant::getId, cursor) + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByAsc(IamTenant::getId) + .last("LIMIT " + (limit + 1)); + if (StringUtils.hasText(keyword)) { + wrapper.and(condition -> condition + .like(IamTenant::getTenantCode, keyword) + .or().like(IamTenant::getTenantName, keyword) + .or().like(IamTenant::getLegalName, keyword) + .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) + .or().like(IamTenant::getContactName, keyword) + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); + } + return tenantMapper.selectList(wrapper); + } } diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index f27869347..8a146106b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -54,64 +54,69 @@ public class ProfileResourceService { } @Transactional - @BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION", - resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false) + @BusinessAudit(event = "TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "上传企业资质", captureAfter = false) public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) { - IamTenant tenant = tenantRepository.findTenant(request.tenantId()); + IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found"); } - if ("ACTIVE".equals(tenant.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved"); + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); } - if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) { - throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review"); + List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() + .filter(item -> request.qualificationType().equals(item.getQualificationType()) + && isCurrentQualification(item)) + .toList(); + if (!activeQualifications.isEmpty() && !request.replace()) { + throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传"); } - if (repository.findQualifications(tenant.getId()).stream() - .anyMatch(item -> !"REJECTED".equals(item.getStatus()))) { - throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image"); + String replacedDocumentUrl = null; + for (IamTenantQualification existing : activeQualifications) { + if (replacedDocumentUrl == null) { + replacedDocumentUrl = existing.getDocumentUrl(); + } + existing.setStatus("SUPERSEDED"); + existing.setReviewComment("商户在送审前替换文件"); + repository.saveQualification(existing); } - IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(), request.certificateNo(), request.documentUrl()); + qualification.setValidTo(request.validTo()); + repository.saveQualification(qualification); if (StringUtils.hasText(request.certificateNo()) && request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) { tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase()); + tenantRepository.saveTenant(tenant); } - tenant.setStatus("PENDING"); - tenant.setRejectReason(null); - tenantRepository.saveTenant(tenant); - - IamTenantReview review = new IamTenantReview(); - review.setId(IdWorker.getId()); review.setTenantId(tenant.getId()); - review.setReviewType("REGISTER"); review.setStatus("PENDING"); - review.setCreatedAt(LocalDateTime.now()); - tenantRepository.insertReview(review); - return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus()); + return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); } @Transactional @BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION", resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false) public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) { - IamTenant tenant = tenantRepository.findTenant(request.tenantId()); + IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); } if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); } - IamTenantQualification existing = repository.findQualificationsForUpdate(tenant.getId()).stream() + List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() .filter(item -> request.qualificationType().equals(item.getQualificationType()) - && !"REJECTED".equals(item.getStatus())) - .findFirst().orElse(null); - if (existing != null && !request.replace()) { + && isCurrentQualification(item)) + .toList(); + if (!activeQualifications.isEmpty() && !request.replace()) { throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传"); } String replacedDocumentUrl = null; - if (existing != null) { - replacedDocumentUrl = existing.getDocumentUrl(); - existing.setStatus("REJECTED"); + for (IamTenantQualification existing : activeQualifications) { + if (replacedDocumentUrl == null) { + replacedDocumentUrl = existing.getDocumentUrl(); + } + existing.setStatus("SUPERSEDED"); existing.setReviewComment("平台在送审前替换文件"); repository.saveQualification(existing); } @@ -130,6 +135,11 @@ public class ProfileResourceService { repository.insertQualification(x); return x; } + private boolean isCurrentQualification(IamTenantQualification qualification) { + return !"REJECTED".equals(qualification.getStatus()) + && !"SUPERSEDED".equals(qualification.getStatus()); + } + private void requireMerchant(long tenantId) { IamTenant tenant = tenantRepository.findTenant(tenantId); if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 4dac81e29..a467b4b03 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.ProfileResourceRepository; @@ -16,6 +17,8 @@ import com.baiyue.iam.dto.TenantDtos.TenantResponse; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; @@ -23,12 +26,15 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; +import java.util.Set; /** * 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。 */ @Service public class TenantService { + private static final Set REQUIRED_QUALIFICATION_TYPES = Set.of( + "BUSINESS_LICENSE", "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE"); @Resource private TenantRepository repository; @Resource @@ -84,6 +90,26 @@ public class TenantService { source.page(), source.size(), source.total(), source.pages()); } + public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) { + int boundedLimit = Math.min(Math.max(limit, 1), 500); + List source = repository.exportMerchants(cursor, boundedLimit, keyword, status); + boolean hasMore = source.size() > boundedLimit; + List page = hasMore ? source.subList(0, boundedLimit) : source; + List rows = page.stream().map(this::exportRow).toList(); + Long nextCursor = page.isEmpty() ? cursor : page.get(page.size() - 1).getId(); + return new CustomerExportBatch(rows, nextCursor, hasMore); + } + + private CustomerExportRow exportRow(IamTenant tenant) { + return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(), + tenant.getTenantName(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), + tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(), + tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(), + tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), + tenant.getSalesOwnerName(), tenant.getServicePackage(), tenant.getPaymentTermDays(), + tenant.getStatus(), tenant.getCreatedAt(), tenant.getApprovedAt()); + } + @BusinessAudit( event = "TENANT_REVIEW", resourceType = "TENANT_REVIEW", @@ -104,9 +130,7 @@ public class TenantService { if (approve) { var qualifications = profileRepository.findQualifications(review.getTenantId()); - boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())); - boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())); - if (!hasLicense || !hasLegalId) { + if (!hasAllPendingQualifications(qualifications)) { throw new BizException(ResultCode.CONFLICT, "Required qualifications are incomplete / 必填资质不完整,不能审核通过"); } } @@ -206,7 +230,7 @@ public class TenantService { @BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId", description = "平台提交企业客户审核") public TenantResponse submitMerchantReview(long tenantId) { - IamTenant tenant = requireMerchant(tenantId); + IamTenant tenant = requireMerchantForUpdate(tenantId); if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { throw new BizException(ResultCode.CONFLICT, "Only draft or rejected enterprise can be submitted / 仅待完善或已驳回客户可提交审核"); } @@ -214,10 +238,9 @@ public class TenantService { throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中"); } var qualifications = profileRepository.findQualifications(tenantId); - boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus())); - boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus())); - if (!hasLicense || !hasLegalId) { - throw new BizException(ResultCode.BAD_REQUEST, "Business license and legal-person ID are required / 营业执照和法人身份证为必填资质"); + if (!hasAllPendingQualifications(qualifications)) { + throw new BizException(ResultCode.BAD_REQUEST, + "Business license, legal-person ID, freight-forwarder certificate and bank account license are required / 营业执照、法人身份证、货代资质证书和银行开户许可证均为必填资质"); } IamTenantReview review = new IamTenantReview(); review.setId(IdWorker.getId()); review.setTenantId(tenantId); @@ -250,6 +273,11 @@ public class TenantService { return tenant.getId() != null && tenant.getId().longValue() == id; } + private boolean hasAllPendingQualifications(List qualifications) { + return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream() + .anyMatch(item -> type.equals(item.getQualificationType()) && "PENDING".equals(item.getStatus()))); + } + private IamTenant requireMerchant(long tenantId) { IamTenant tenant = repository.findTenant(tenantId); if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) { @@ -258,6 +286,14 @@ public class TenantService { return tenant; } + private IamTenant requireMerchantForUpdate(long tenantId) { + IamTenant tenant = repository.findTenantForUpdate(tenantId); + if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在"); + } + return tenant; + } + private TenantResponse response(IamTenant tenant) { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 1f6a8507b..fe20d0941 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -88,10 +88,13 @@ VALUES (5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0), (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0), -- Platform: buttons. (5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0), (5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 0), (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0), (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0), (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0), @@ -198,7 +201,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission WHERE permission_code IN ( 'platform:customer', 'platform:customer:list', 'platform:customer:review', - 'platform:customer:approve', 'platform:customer:reject' + 'platform:customer:approve', 'platform:customer:reject', 'platform:customer:export', + 'platform:master-data', 'platform:download:list' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql index 8f91f3199..93e4551e0 100644 --- a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql +++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql @@ -18,6 +18,16 @@ UPDATE iam_permission SET built_in = 1 WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0; +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, sort_no, status, built_in, deleted) +VALUES + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', + '/downloads', 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, built_in, deleted) @@ -25,6 +35,7 @@ VALUES (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0), (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0), (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0), @@ -35,7 +46,8 @@ VALUES (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0), (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0), (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0), - (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0) + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', 10, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0; @@ -49,10 +61,24 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role', 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete', 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import', - 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status' + 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status', + 'platform:customer:export', 'platform:download:list', 'platform:download:all' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 已有客户运营角色继续具备客户导出与本人下载任务查询能力,但不授予查看全部任务。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id +FROM iam_role_permission existing_grant +JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id +JOIN iam_permission added_permission + ON added_permission.tenant_id = 0 AND added_permission.app_code = 'PLATFORM' + AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list') +WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + -- 详情查看从列表和审核权限中拆分;升级时保留既有运营角色的查询能力。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 25c597710..6d4313c9f 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -464,6 +464,16 @@ paths: responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} + /api/merchant/v1/iam/tenant/qualifications/submit-review: + post: + tags: [IAM Resources / IAM 资源] + summary: Submit enterprise qualifications for review / 提交企业资质审核 + description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review only after all four required qualification types have been provided. / 上传或替换资质不会自动送审;四类必填资质齐全后由此接口创建审核单。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/{app}/v1/iam/roles: parameters: - {$ref: '#/components/parameters/App'} @@ -749,7 +759,7 @@ components: documentUrl: {type: string} validFrom: {type: string, format: date} validTo: {type: string, format: date, nullable: true} - status: {type: string, enum: [PENDING, APPROVED, REJECTED]} + status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]} reviewComment: {type: string} RegisterRequest: type: object diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java new file mode 100644 index 000000000..d8a786482 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.iam.service.MerchantOnboardingService; +import com.baiyue.iam.service.ProfileResourceService; +import com.baiyue.iam.service.TenantService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InternalCustomerExportContractTest { + @Test + void rejectsMissingInternalTokenAndAcceptsConfiguredToken() { + TenantService service = mock(TenantService.class); + InternalTenantController controller = new InternalTenantController(); + ReflectionTestUtils.setField(controller, "onboardingService", mock(MerchantOnboardingService.class)); + ReflectionTestUtils.setField(controller, "profileResourceService", mock(ProfileResourceService.class)); + ReflectionTestUtils.setField(controller, "tenantService", service); + ReflectionTestUtils.setField(controller, "internalTokenValue", "internal-token"); + controller.initializeToken(); + + BizException denied = assertThrows(BizException.class, + () -> controller.exportCustomers(null, null, 100, null, null)); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + + when(service.exportMerchants(null, 100, "柏悦", "ACTIVE")) + .thenReturn(new CustomerExportBatch(List.of(), null, false)); + var response = controller.exportCustomers("internal-token", null, 100, "柏悦", "ACTIVE"); + assertEquals(ResultCode.SUCCESS.code(), response.code()); + verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java index cc6af9acd..9dbf1b24c 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java @@ -35,6 +35,7 @@ class QualificationUploadContractTest { assertFalse(pathContract.contains("\n post:")); assertFalse(yaml.contains(" QualificationRequest:")); + assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:")); } } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java index 9e0ae9496..0b03270da 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -48,9 +48,11 @@ class CustomerProfileMigrationContractTest { .contains("CALL regression_assert_table('iam_tenant')") .contains("CREATE PROCEDURE regression_add_iam_column") .contains("platform:customer:view") + .contains("platform:customer:export") + .contains("platform:download:list") .contains("'IAM客户档案字段' AS check_item") .contains("9 AS expected_count") - .contains("44 AS expected_count") + .contains("47 AS expected_count") .contains("MIGRATION_COMPLETED"); assertThat(instructions) .contains("phase1-platform-master-data-and-permission-management.sql") diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java new file mode 100644 index 000000000..c68a18c1b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantQualificationUploadTest { + + @Test + void uploadsOneDraftQualificationWithoutCreatingAReview() { + TenantRepository tenantRepository = mock(TenantRepository.class); + ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); + ProfileResourceService service = new ProfileResourceService(); + ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); + ReflectionTestUtils.setField(service, "repository", profileRepository); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); + + var response = service.submitQualification(new QualificationSubmissionRequest( + 10001L, "LEGAL_PERSON_ID", null, + "/api/merchant/v1/files/qualifications/legal-person.png", LocalDate.of(2030, 1, 1), false)); + + assertEquals("PROFILE_REQUIRED", response.tenantStatus()); + assertNull(response.reviewId()); + verify(profileRepository).insertQualification(any()); + verify(tenantRepository, never()).insertReview(any()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java index d08e8909d..e31ec75a6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -29,7 +29,7 @@ class PlatformQualificationRegistrationTest { ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); - when(tenantRepository.findTenant(10001L)).thenReturn(tenant); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( @@ -59,7 +59,7 @@ class PlatformQualificationRegistrationTest { existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID"); existing.setStatus("PENDING"); existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf"); - when(tenantRepository.findTenant(10001L)).thenReturn(tenant); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing)); var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( @@ -68,7 +68,7 @@ class PlatformQualificationRegistrationTest { ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); verify(repository, times(2)).saveQualification(captor.capture()); - assertEquals("REJECTED", existing.getStatus()); + assertEquals("SUPERSEDED", existing.getStatus()); assertEquals("平台在送审前替换文件", existing.getReviewComment()); assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl()); assertEquals("PENDING", captor.getAllValues().get(1).getStatus()); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java new file mode 100644 index 000000000..2786137be --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -0,0 +1,42 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TenantCustomerExportTest { + @Test + void returnsUnmaskedCustomerRowsAndAdvancingCursor() { + TenantRepository repository = mock(TenantRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "profileRepository", mock(ProfileResourceRepository.class)); + IamTenant tenant = new IamTenant(); + tenant.setId(101L); + tenant.setTenantCode("TEN101"); + tenant.setTenantName("柏悦物流"); + tenant.setContactPhone("13800138000"); + tenant.setContactEmail("ops@example.com"); + tenant.setStatus("ACTIVE"); + when(repository.exportMerchants(100L, 50, "柏悦", "ACTIVE")).thenReturn(List.of(tenant)); + + var result = service.exportMerchants(100L, 50, "柏悦", "ACTIVE"); + + assertEquals("101", result.records().get(0).id()); + assertEquals("13800138000", result.records().get(0).contactPhone()); + assertEquals("ops@example.com", result.records().get(0).contactEmail()); + assertEquals(101L, result.nextCursor()); + assertFalse(result.hasMore()); + verify(repository).exportMerchants(100L, 50, "柏悦", "ACTIVE"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index c3b20b274..1197047b4 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -125,16 +125,17 @@ class TenantServiceTest { } @Test - void explicitSubmissionRequiresBothMandatoryQualificationsAndCreatesReview() { + void explicitSubmissionRequiresAllPrototypeQualificationsAndCreatesReview() { TenantRepository repository = mock(TenantRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); TenantService service = new TenantService(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "profileRepository", profileRepository); IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); - when(repository.findTenant(10001L)).thenReturn(tenant); + when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); when(profileRepository.findQualifications(10001L)).thenReturn(List.of( - qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"))); + qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), + qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE"))); var submitted = service.submitMerchantReview(10001L); @@ -143,6 +144,23 @@ class TenantServiceTest { verify(repository).saveTenant(tenant); } + @Test + void explicitSubmissionRejectsAnIncompleteQualificationSet() { + TenantRepository repository = mock(TenantRepository.class); + ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "profileRepository", profileRepository); + when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED")); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"))); + + BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L)); + + assertEquals(ResultCode.BAD_REQUEST, exception.getResultCode()); + verify(repository, never()).insertReview(any()); + } + @Test void 企业激活不等于具备下单资格仍需已审核资质() { TenantRepository repository = mock(TenantRepository.class);