This commit is contained in:
2026-08-03 18:28:35 +08:00
parent eac3da9dd8
commit fdb1e8af1c
43 changed files with 1707 additions and 69 deletions
+2 -1
View File
@@ -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
@@ -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;
@@ -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 下载中心覆盖加载、空数据、失败、处理中、完成、失败、过期和窄屏操作状态。
@@ -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`:校验企业资质访问权限
+1
View File
@@ -5,6 +5,7 @@
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>file-service</artifactId>
<dependencies>
<dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.3.0</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
@@ -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);
@@ -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<CustomerExportBatch> customers(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "500") int limit,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status);
record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
record CustomerExportRow(
String id, String tenantCode, String tenantName, String legalName,
String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
String contactName, String contactPhone, String contactEmail, String contactWechat,
String salesOwnerName, String servicePackage, Integer paymentTermDays,
String status, LocalDateTime createdAt, LocalDateTime approvedAt) { }
}
@@ -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,
@@ -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<ExportTaskResponse> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CreateExportTaskRequest request) {
return ApiResponse.success(service.create(user, request));
}
@GetMapping
@PreAuthorize("hasAuthority('platform:download:list')")
public ApiResponse<PageResult<ExportTaskResponse>> 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<ExportTaskResponse> 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<org.springframework.core.io.Resource> 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<ExportTaskResponse> retry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id) {
return ApiResponse.success(service.retry(user, id));
}
}
@@ -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<UploadResponse> 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) {
@@ -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<String, Object> 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) { }
}
@@ -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;
}
@@ -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<FileExportTask> { }
@@ -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<FileExportTask> page(long page, long size, long tenantId, String appCode,
Long requestedBy, String exportType, String status,
LocalDate requestedFrom, LocalDate requestedTo) {
var query = new LambdaQueryWrapper<FileExportTask>()
.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<FileExportTask>()
.select(FileExportTask::getId)
.eq(FileExportTask::getStatus, "PENDING")
.orderByAsc(FileExportTask::getRequestedAt)
.orderByAsc(FileExportTask::getId)
.last("LIMIT 1"));
return task == null ? null : task.getId();
}
public boolean claim(long id, LocalDateTime now) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "PENDING")
.set(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getStartedAt, now)
.set(FileExportTask::getFailureMessage, null)) == 1;
}
public void progress(long id, long rows) {
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getProcessedRows, rows));
}
public boolean succeed(long id, String fileName, String objectKey, String contentType,
long sizeBytes, long rows, LocalDateTime completedAt, LocalDateTime expiresAt) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getStatus, "SUCCEEDED")
.set(FileExportTask::getFileName, fileName)
.set(FileExportTask::getObjectKey, objectKey)
.set(FileExportTask::getContentType, contentType)
.set(FileExportTask::getSizeBytes, sizeBytes)
.set(FileExportTask::getTotalRows, rows)
.set(FileExportTask::getProcessedRows, rows)
.set(FileExportTask::getCompletedAt, completedAt)
.set(FileExportTask::getExpiresAt, expiresAt)) == 1;
}
public void fail(long id, String message, LocalDateTime completedAt) {
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getStatus, "FAILED")
.set(FileExportTask::getFailureMessage, message)
.set(FileExportTask::getCompletedAt, completedAt));
}
public boolean retry(long id) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "FAILED")
.set(FileExportTask::getStatus, "PENDING")
.set(FileExportTask::getProcessedRows, 0L)
.set(FileExportTask::getTotalRows, null)
.set(FileExportTask::getFailureMessage, null)
.set(FileExportTask::getStartedAt, null)
.set(FileExportTask::getCompletedAt, null)) == 1;
}
public int requeueStale(LocalDateTime cutoff) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getStatus, "RUNNING")
.lt(FileExportTask::getUpdatedAt, cutoff)
.set(FileExportTask::getStatus, "PENDING")
.set(FileExportTask::getStartedAt, null)
.set(FileExportTask::getFailureMessage, "Previous worker stopped before completion; task requeued"));
}
public List<FileExportTask> expiredArtifacts(LocalDateTime now) {
return mapper.selectList(new LambdaQueryWrapper<FileExportTask>()
.eq(FileExportTask::getStatus, "SUCCEEDED")
.le(FileExportTask::getExpiresAt, now)
.isNotNull(FileExportTask::getObjectKey)
.last("LIMIT 50"));
}
public void markExpired(long id) {
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "SUCCEEDED")
.set(FileExportTask::getStatus, "EXPIRED"));
}
}
@@ -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<String> HEADERS = List.of(
"客户ID", "客户编码", "公司名称", "营业执照企业名称", "统一社会信用代码", "企业类型",
"成立日期", "注册资本", "币种", "注册地址", "联系人", "联系电话", "联系邮箱", "联系微信",
"销售人员", "客户套餐", "账期(天)", "状态", "入驻时间", "审批时间");
private final IamExportClient iamClient;
private final LocalExportStorage storage;
private final ObjectMapper objectMapper;
private final String internalToken;
public CustomerExportTaskHandler(IamExportClient iamClient, LocalExportStorage storage,
ObjectMapper objectMapper,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.iamClient = iamClient;
this.storage = storage;
this.objectMapper = objectMapper;
this.internalToken = internalToken;
}
@Override public String exportType() { return "CUSTOMER_LIST"; }
@Override public String requiredPermission() { return "platform:customer:export"; }
@Override
public void validateParameters(Map<String, Object> parameters) {
if (parameters == null) return;
Object keyword = parameters.get("keyword");
if (keyword != null && String.valueOf(keyword).length() > 128) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出关键词不能超过 128 个字符");
}
Object status = parameters.get("status");
if (status != null && !String.valueOf(status).isBlank()
&& !Set.of("PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED")
.contains(String.valueOf(status))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出状态无效");
}
}
@Override
public ExportResult export(FileExportTask task, ProgressReporter progress) {
CustomerParameters parameters = parameters(task.getParametersJson());
LocalExportStorage.PendingFile target = storage.create(task.getId());
long rows = 0;
try (target; SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
workbook.setCompressTempFiles(true);
Sheet sheet = workbook.createSheet("客户列表");
writeHeader(workbook, sheet);
Long cursor = null;
boolean hasMore;
do {
var response = iamClient.customers(internalToken, cursor, 500,
blankToNull(parameters.keyword()), blankToNull(parameters.status()));
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM customer export is unavailable");
}
var batch = response.data();
for (var item : batch.records()) writeRow(sheet.createRow(Math.toIntExact(++rows)), item);
progress.update(rows);
hasMore = batch.hasMore();
if (hasMore && (batch.nextCursor() == null || batch.nextCursor().equals(cursor))) {
throw new IllegalStateException("IAM customer export cursor did not advance");
}
cursor = batch.nextCursor();
} while (hasMore);
for (int index = 0; index < HEADERS.size(); index++) sheet.setColumnWidth(index, columnWidth(index));
workbook.write(target.output());
} catch (IOException exception) {
storage.delete(target.objectKey());
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户导出文件生成失败");
} catch (RuntimeException exception) {
storage.delete(target.objectKey());
throw exception;
}
String fileName = "客户列表-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
return new ExportResult(fileName, target.objectKey(), CONTENT_TYPE,
storage.size(target.objectKey()), rows);
}
private CustomerParameters parameters(String json) {
try { return objectMapper.readValue(json, CustomerParameters.class); }
catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "客户导出筛选条件无效"); }
}
private void writeHeader(Workbook workbook, Sheet sheet) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
var font = workbook.createFont();
font.setBold(true);
style.setFont(font);
Row row = sheet.createRow(0);
for (int index = 0; index < HEADERS.size(); index++) {
var cell = row.createCell(index);
cell.setCellValue(HEADERS.get(index));
cell.setCellStyle(style);
}
sheet.createFreezePane(0, 1);
}
private void writeRow(Row row, IamExportClient.CustomerExportRow item) {
int column = 0;
cell(row, column++, item.id()); cell(row, column++, item.tenantCode());
cell(row, column++, item.tenantName()); cell(row, column++, item.legalName());
cell(row, column++, item.unifiedSocialCreditCode()); cell(row, column++, item.enterpriseType());
cell(row, column++, item.establishedDate()); cell(row, column++, item.registeredCapital());
cell(row, column++, item.registeredCapitalCurrency()); cell(row, column++, item.registeredAddress());
cell(row, column++, item.contactName()); cell(row, column++, item.contactPhone());
cell(row, column++, item.contactEmail()); cell(row, column++, item.contactWechat());
cell(row, column++, item.salesOwnerName()); cell(row, column++, item.servicePackage());
cell(row, column++, item.paymentTermDays()); cell(row, column++, statusLabel(item.status()));
cell(row, column++, item.createdAt()); cell(row, column, item.approvedAt());
}
private void cell(Row row, int column, Object value) {
row.createCell(column).setCellValue(value == null ? "" : String.valueOf(value));
}
private int columnWidth(int index) {
return switch (index) {
case 2, 3, 9 -> 28 * 256;
case 4, 12 -> 24 * 256;
case 18, 19 -> 21 * 256;
default -> 16 * 256;
};
}
private String statusLabel(String status) {
if (status == null) return "";
return switch (status) {
case "PROFILE_REQUIRED" -> "待完善";
case "PENDING" -> "待审核";
case "ACTIVE" -> "启用";
case "REJECTED" -> "已驳回";
case "DISABLED" -> "已停用";
default -> status;
};
}
private String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
public record CustomerParameters(String keyword, String status) { }
}
@@ -0,0 +1,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<String, Object> parameters) { }
ExportResult export(FileExportTask task, ProgressReporter progress);
record ExportResult(String fileName, String objectKey, String contentType, long sizeBytes, long rows) { }
@FunctionalInterface
interface ProgressReporter { void update(long rows); }
}
@@ -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<String, ExportTaskHandler> handlers;
public ExportTaskService(ExportTaskRepository repository, LocalExportStorage storage,
ObjectMapper objectMapper, List<ExportTaskHandler> 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<ExportTaskResponse> 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<FileExportTask> 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<String, Object> 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) { }
}
@@ -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);
}
}
@@ -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(); }
}
}
+4
View File
@@ -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:
+36
View File
@@ -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='公共异步导出任务表';
@@ -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());
}
}
@@ -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();
@@ -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());
}
}
}
@@ -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<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
}
}
@@ -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);
}
}
@@ -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<CustomerExportBatch> 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)) {
@@ -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<TenantResponse> 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 / 查询成功")})
@@ -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<CustomerExportRow> 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) { }
}
@@ -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,
@@ -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<IamTenant>()
.eq(IamTenant::getId, tenantId)
.last("FOR UPDATE"));
}
public List<IamTenant> listMerchants() {
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
@@ -71,4 +81,24 @@ public class TenantRepository {
}
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
}
public List<IamTenant> exportMerchants(Long cursor, int limit, String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
.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);
}
}
@@ -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<IamTenantQualification> 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<IamTenantQualification> 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
@@ -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<String> 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<IamTenant> source = repository.exportMerchants(cursor, boundedLimit, keyword, status);
boolean hasMore = source.size() > boundedLimit;
List<IamTenant> page = hasMore ? source.subList(0, boundedLimit) : source;
List<CustomerExportRow> 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<IamTenantQualification> 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(),
@@ -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);
@@ -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
+11 -1
View File
@@ -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
@@ -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");
}
}
@@ -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:"));
}
}
}
@@ -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")
@@ -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());
}
}
@@ -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<IamTenantQualification> 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());
@@ -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");
}
}
@@ -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);