优化
This commit is contained in:
@@ -33,7 +33,8 @@ for sql_file in \
|
|||||||
warehouse/src/main/resources/db/warehouse-partner.sql \
|
warehouse/src/main/resources/db/warehouse-partner.sql \
|
||||||
warehouse/src/main/resources/db/warehouse-express-company.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/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
|
do
|
||||||
if [ ! -f "$sql_file" ]; then
|
if [ ! -f "$sql_file" ]; then
|
||||||
echo "找不到初始化 SQL:$sql_file" >&2
|
echo "找不到初始化 SQL:$sql_file" >&2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- 一期回归测试环境增量迁移汇总
|
-- 一期回归测试环境增量迁移汇总
|
||||||
-- 适用版本:MySQL 8.0
|
-- 适用版本:MySQL 8.0
|
||||||
-- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg
|
-- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg、baiyue_file
|
||||||
-- 安全边界:不创建数据库、不清表、不删除业务数据、不写入演示租户及演示业务数据。
|
-- 安全边界:不创建数据库、不清表、不删除业务数据、不写入演示租户及演示业务数据。
|
||||||
-- 可重复执行:所有新增字段、表、索引和权限均带存在性判断或幂等写入。
|
-- 可重复执行:所有新增字段、表、索引和权限均带存在性判断或幂等写入。
|
||||||
-- 重要说明:MySQL DDL 会隐式提交,本脚本不能作为一个跨库事务整体回滚。
|
-- 重要说明:MySQL DDL 会隐式提交,本脚本不能作为一个跨库事务整体回滚。
|
||||||
@@ -100,7 +100,8 @@ VALUES
|
|||||||
(5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 1, 0),
|
(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),
|
(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),
|
(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
|
ON DUPLICATE KEY UPDATE
|
||||||
permission_type = VALUES(permission_type),
|
permission_type = VALUES(permission_type),
|
||||||
parent_id = VALUES(parent_id),
|
parent_id = VALUES(parent_id),
|
||||||
@@ -120,6 +121,7 @@ VALUES
|
|||||||
(5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', NULL, NULL, 15, 'ACTIVE', 1, 0),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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),
|
(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
|
ON DUPLICATE KEY UPDATE
|
||||||
permission_type = VALUES(permission_type),
|
permission_type = VALUES(permission_type),
|
||||||
parent_id = VALUES(parent_id),
|
parent_id = VALUES(parent_id),
|
||||||
@@ -203,7 +206,7 @@ SELECT r.id, p.id, 0
|
|||||||
AND r.deleted = 0
|
AND r.deleted = 0
|
||||||
AND p.permission_code IN (
|
AND p.permission_code IN (
|
||||||
'platform:customer', 'platform:customer:list', 'platform:customer:create',
|
'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:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit',
|
||||||
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
||||||
'platform:role:list', 'platform:role:create', 'platform:role:grant',
|
'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:master-data', 'platform:express:list', 'platform:express:create',
|
||||||
'platform:express:edit', 'platform:express:status',
|
'platform:express:edit', 'platform:express:status',
|
||||||
'platform:counterparty:list', 'platform:counterparty:create',
|
'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);
|
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')
|
AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review')
|
||||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
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)
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||||
SELECT r.id, p.id, r.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;
|
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
|
SELECT
|
||||||
'IAM权限资源' AS check_item,
|
'IAM权限资源' AS check_item,
|
||||||
COUNT(*) AS actual_count,
|
COUNT(*) AS actual_count,
|
||||||
44 AS expected_count,
|
47 AS expected_count,
|
||||||
IF(COUNT(*) = 44, '通过', '请检查缺失权限') AS check_result
|
IF(COUNT(*) = 47, '通过', '请检查缺失权限') AS check_result
|
||||||
FROM baiyue_iam.iam_permission
|
FROM baiyue_iam.iam_permission
|
||||||
WHERE deleted = 0
|
WHERE deleted = 0
|
||||||
AND permission_code IN (
|
AND permission_code IN (
|
||||||
'platform:customer', 'platform:customer:list', 'platform:customer:create',
|
'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:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit',
|
||||||
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
||||||
'platform:role:list', 'platform:role:create', 'platform:role:grant',
|
'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:master-data', 'platform:express:list', 'platform:express:create',
|
||||||
'platform:express:edit', 'platform:express:status',
|
'platform:express:edit', 'platform:express:status',
|
||||||
'platform:counterparty:list', 'platform:counterparty:create',
|
'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
|
SELECT
|
||||||
@@ -705,5 +765,12 @@ WHERE table_schema = 'baiyue_firstleg'
|
|||||||
AND table_name = 'fl_fulfillment_task'
|
AND table_name = 'fl_fulfillment_task'
|
||||||
AND index_name = 'idx_task_tenant_created';
|
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,
|
SELECT 'MIGRATION_COMPLETED' AS migration_status,
|
||||||
'请确认上方所有核验结果均为通过后再启动服务回归' AS message;
|
'请确认上方所有核验结果均为通过后再启动服务回归' 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` | 履约任务、操作分配和分配历史;不是订单主数据服务 |
|
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务、操作分配和分配历史;不是订单主数据服务 |
|
||||||
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
|
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
|
||||||
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、仓库地址、服务能力、启停校验,以及往来单位查询;往来单位不单独拆服务 |
|
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、仓库地址、服务能力、启停校验,以及往来单位查询;往来单位不单独拆服务 |
|
||||||
| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;当前主要实现企业资质、营业执照上传和预览 |
|
| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;实现企业资质上传预览及公共异步导出、下载中心 |
|
||||||
| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 |
|
| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 |
|
||||||
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
|
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
|
||||||
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
|
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
|
||||||
@@ -177,6 +177,8 @@ IAM 以 `tenant_id` 作为核心隔离维度,同时维护两套访问域:商
|
|||||||
|
|
||||||
- `QualificationFileController`:商户端企业资质文件
|
- `QualificationFileController`:商户端企业资质文件
|
||||||
- `PlatformQualificationFileController`:平台端鉴权查看
|
- `PlatformQualificationFileController`:平台端鉴权查看
|
||||||
|
- `PlatformExportTaskController`:平台异步导出任务、下载与失败重试
|
||||||
|
- `ExportTaskHandler`:业务导出类型扩展点;接入规则见 `docs/公共导出与下载中心接入说明.md`
|
||||||
- `QualificationFileStorage`:存储抽象
|
- `QualificationFileStorage`:存储抽象
|
||||||
- `LocalQualificationStorage`:当前本地磁盘实现
|
- `LocalQualificationStorage`:当前本地磁盘实现
|
||||||
- `IamQualificationClient`:校验企业资质访问权限
|
- `IamQualificationClient`:校验企业资质访问权限
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<artifactId>file-service</artifactId>
|
||||||
<dependencies>
|
<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-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.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>
|
<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.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件中心服务启动入口。
|
* 文件中心服务启动入口。
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||||
@EnableFeignClients(basePackages = "com.baiyue.file.client")
|
@EnableFeignClients(basePackages = "com.baiyue.file.client")
|
||||||
|
@EnableScheduling
|
||||||
public class FileServiceApplication {
|
public class FileServiceApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(FileServiceApplication.class, 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);
|
@RequestBody PlatformQualificationRequest request);
|
||||||
|
|
||||||
record QualificationRequest(Long tenantId, String qualificationType,
|
record QualificationRequest(Long tenantId, String qualificationType,
|
||||||
String certificateNo, String documentUrl) { }
|
String certificateNo, String documentUrl,
|
||||||
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
|
java.time.LocalDate validTo, boolean replace) { }
|
||||||
|
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
|
||||||
|
String replacedDocumentUrl) { }
|
||||||
record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
|
record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
|
||||||
String replacedDocumentUrl) { }
|
String replacedDocumentUrl) { }
|
||||||
record PlatformQualificationRequest(Long tenantId, String qualificationType,
|
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.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/merchant/v1/files/qualifications")
|
@RequestMapping("/api/merchant/v1/files/qualifications")
|
||||||
public class QualificationFileController {
|
public class QualificationFileController {
|
||||||
@@ -31,13 +33,16 @@ public class QualificationFileController {
|
|||||||
public ApiResponse<UploadResponse> upload(
|
public ApiResponse<UploadResponse> upload(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestPart("file") MultipartFile file,
|
@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);
|
long tenantId = merchantTenant(user);
|
||||||
var stored = storage.store(tenantId, file);
|
var stored = storage.store(tenantId, file);
|
||||||
try {
|
try {
|
||||||
var response = iamClient.submit(internalToken,
|
var response = iamClient.submit(internalToken,
|
||||||
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
|
new IamQualificationClient.QualificationRequest(tenantId, qualificationType,
|
||||||
certificateNo, stored.documentUrl()));
|
certificateNo, stored.documentUrl(), validTo, replace));
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
throw new IllegalStateException("IAM qualification response is empty");
|
throw new IllegalStateException("IAM qualification response is empty");
|
||||||
}
|
}
|
||||||
@@ -52,6 +57,9 @@ public class QualificationFileController {
|
|||||||
throw new IllegalStateException("IAM qualification response data is empty");
|
throw new IllegalStateException("IAM qualification response data is empty");
|
||||||
}
|
}
|
||||||
var data = response.data();
|
var data = response.data();
|
||||||
|
if (!stored.documentUrl().equals(data.replacedDocumentUrl())) {
|
||||||
|
storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl());
|
||||||
|
}
|
||||||
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
|
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
|
||||||
data.tenantStatus(), stored.documentUrl()));
|
data.tenantStatus(), stored.documentUrl()));
|
||||||
} catch (BizException exception) {
|
} 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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ baiyue:
|
|||||||
url: ${IAM_SERVICE_URL:}
|
url: ${IAM_SERVICE_URL:}
|
||||||
file:
|
file:
|
||||||
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
|
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
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
@@ -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_file (tenant_id, file_id, downloaded_at),
|
||||||
KEY idx_download_log_user (tenant_id, user_id, downloaded_at)
|
KEY idx_download_log_user (tenant_id, user_id, downloaded_at)
|
||||||
) ENGINE=InnoDB COMMENT='文件下载审计日志表';
|
) 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='公共异步导出任务表';
|
||||||
|
|||||||
+23
@@ -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"));
|
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
|
||||||
|
|
||||||
var exception = assertThrows(BizException.class,
|
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());
|
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
|
||||||
verify(storage, never()).delete(stored);
|
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(
|
private static PlatformQualificationFileController platformController(
|
||||||
LocalQualificationStorage storage, IamQualificationClient iamClient) {
|
LocalQualificationStorage storage, IamQualificationClient iamClient) {
|
||||||
var controller = new PlatformQualificationFileController();
|
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.MerchantOnboardingService;
|
||||||
import com.baiyue.iam.service.ProfileResourceService;
|
import com.baiyue.iam.service.ProfileResourceService;
|
||||||
import com.baiyue.iam.service.TenantService;
|
import com.baiyue.iam.service.TenantService;
|
||||||
|
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ import java.security.MessageDigest;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/iam")
|
@RequestMapping("/internal/iam")
|
||||||
|
@Validated
|
||||||
public class InternalTenantController {
|
public class InternalTenantController {
|
||||||
@Resource private MerchantOnboardingService onboardingService;
|
@Resource private MerchantOnboardingService onboardingService;
|
||||||
@Resource private ProfileResourceService profileResourceService;
|
@Resource private ProfileResourceService profileResourceService;
|
||||||
@@ -66,6 +72,18 @@ public class InternalTenantController {
|
|||||||
return ApiResponse.success(tenantService.orderEligibility(tenantId));
|
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) {
|
private void verify(String token) {
|
||||||
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
||||||
if (!MessageDigest.isEqual(internalToken, supplied)) {
|
if (!MessageDigest.isEqual(internalToken, supplied)) {
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ public class MerchantTenantController {
|
|||||||
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
|
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")
|
@GetMapping("/members")
|
||||||
@Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。")
|
@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 / 查询成功")})
|
@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(
|
public record QualificationSubmissionRequest(
|
||||||
@NotNull Long tenantId,
|
@NotNull Long tenantId,
|
||||||
@NotBlank String qualificationType,
|
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
|
||||||
String certificateNo,
|
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(
|
public record PlatformQualificationRegistrationRequest(
|
||||||
@NotNull Long tenantId,
|
@NotNull Long tenantId,
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ public class TenantRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
|
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() {
|
public List<IamTenant> listMerchants() {
|
||||||
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
|
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
|
||||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||||
@@ -71,4 +81,24 @@ public class TenantRepository {
|
|||||||
}
|
}
|
||||||
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
|
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
|
@Transactional
|
||||||
@BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION",
|
@BusinessAudit(event = "TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
|
||||||
resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false)
|
resourceId = "#request.tenantId", description = "上传企业资质", captureAfter = false)
|
||||||
public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) {
|
public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) {
|
||||||
IamTenant tenant = tenantRepository.findTenant(request.tenantId());
|
IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId());
|
||||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found");
|
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found");
|
||||||
}
|
}
|
||||||
if ("ACTIVE".equals(tenant.getStatus())) {
|
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved");
|
throw new BizException(ResultCode.CONFLICT,
|
||||||
|
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||||
}
|
}
|
||||||
if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) {
|
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||||
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review");
|
.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()
|
String replacedDocumentUrl = null;
|
||||||
.anyMatch(item -> !"REJECTED".equals(item.getStatus()))) {
|
for (IamTenantQualification existing : activeQualifications) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image");
|
if (replacedDocumentUrl == null) {
|
||||||
|
replacedDocumentUrl = existing.getDocumentUrl();
|
||||||
|
}
|
||||||
|
existing.setStatus("SUPERSEDED");
|
||||||
|
existing.setReviewComment("商户在送审前替换文件");
|
||||||
|
repository.saveQualification(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
IamTenantQualification qualification = createQualification(tenant.getId(),
|
IamTenantQualification qualification = createQualification(tenant.getId(),
|
||||||
request.qualificationType(), request.certificateNo(), request.documentUrl());
|
request.qualificationType(), request.certificateNo(), request.documentUrl());
|
||||||
|
qualification.setValidTo(request.validTo());
|
||||||
|
repository.saveQualification(qualification);
|
||||||
if (StringUtils.hasText(request.certificateNo())
|
if (StringUtils.hasText(request.certificateNo())
|
||||||
&& request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) {
|
&& request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) {
|
||||||
tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase());
|
tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase());
|
||||||
|
tenantRepository.saveTenant(tenant);
|
||||||
}
|
}
|
||||||
tenant.setStatus("PENDING");
|
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
|
@BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
|
||||||
resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false)
|
resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false)
|
||||||
public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
|
public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
|
||||||
IamTenant tenant = tenantRepository.findTenant(request.tenantId());
|
IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId());
|
||||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
|
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
|
||||||
}
|
}
|
||||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
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())
|
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||||
&& !"REJECTED".equals(item.getStatus()))
|
&& isCurrentQualification(item))
|
||||||
.findFirst().orElse(null);
|
.toList();
|
||||||
if (existing != null && !request.replace()) {
|
if (!activeQualifications.isEmpty() && !request.replace()) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
|
throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
|
||||||
}
|
}
|
||||||
String replacedDocumentUrl = null;
|
String replacedDocumentUrl = null;
|
||||||
if (existing != null) {
|
for (IamTenantQualification existing : activeQualifications) {
|
||||||
replacedDocumentUrl = existing.getDocumentUrl();
|
if (replacedDocumentUrl == null) {
|
||||||
existing.setStatus("REJECTED");
|
replacedDocumentUrl = existing.getDocumentUrl();
|
||||||
|
}
|
||||||
|
existing.setStatus("SUPERSEDED");
|
||||||
existing.setReviewComment("平台在送审前替换文件");
|
existing.setReviewComment("平台在送审前替换文件");
|
||||||
repository.saveQualification(existing);
|
repository.saveQualification(existing);
|
||||||
}
|
}
|
||||||
@@ -130,6 +135,11 @@ public class ProfileResourceService {
|
|||||||
repository.insertQualification(x); return x;
|
repository.insertQualification(x); return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isCurrentQualification(IamTenantQualification qualification) {
|
||||||
|
return !"REJECTED".equals(qualification.getStatus())
|
||||||
|
&& !"SUPERSEDED".equals(qualification.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
private void requireMerchant(long tenantId) {
|
private void requireMerchant(long tenantId) {
|
||||||
IamTenant tenant = tenantRepository.findTenant(tenantId);
|
IamTenant tenant = tenantRepository.findTenant(tenantId);
|
||||||
if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != 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.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.iam.entity.IamTenant;
|
import com.baiyue.iam.entity.IamTenant;
|
||||||
|
import com.baiyue.iam.entity.IamTenantQualification;
|
||||||
import com.baiyue.iam.entity.IamTenantReview;
|
import com.baiyue.iam.entity.IamTenantReview;
|
||||||
import com.baiyue.iam.repository.TenantRepository;
|
import com.baiyue.iam.repository.TenantRepository;
|
||||||
import com.baiyue.iam.repository.ProfileResourceRepository;
|
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.UpdateProfileRequest;
|
||||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
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 com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -23,12 +26,15 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
|
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TenantService {
|
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
|
@Resource
|
||||||
private TenantRepository repository;
|
private TenantRepository repository;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -84,6 +90,26 @@ public class TenantService {
|
|||||||
source.page(), source.size(), source.total(), source.pages());
|
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(
|
@BusinessAudit(
|
||||||
event = "TENANT_REVIEW",
|
event = "TENANT_REVIEW",
|
||||||
resourceType = "TENANT_REVIEW",
|
resourceType = "TENANT_REVIEW",
|
||||||
@@ -104,9 +130,7 @@ public class TenantService {
|
|||||||
|
|
||||||
if (approve) {
|
if (approve) {
|
||||||
var qualifications = profileRepository.findQualifications(review.getTenantId());
|
var qualifications = profileRepository.findQualifications(review.getTenantId());
|
||||||
boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus()));
|
if (!hasAllPendingQualifications(qualifications)) {
|
||||||
boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus()));
|
|
||||||
if (!hasLicense || !hasLegalId) {
|
|
||||||
throw new BizException(ResultCode.CONFLICT, "Required qualifications are incomplete / 必填资质不完整,不能审核通过");
|
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",
|
@BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId",
|
||||||
description = "平台提交企业客户审核")
|
description = "平台提交企业客户审核")
|
||||||
public TenantResponse submitMerchantReview(long tenantId) {
|
public TenantResponse submitMerchantReview(long tenantId) {
|
||||||
IamTenant tenant = requireMerchant(tenantId);
|
IamTenant tenant = requireMerchantForUpdate(tenantId);
|
||||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "Only draft or rejected enterprise can be submitted / 仅待完善或已驳回客户可提交审核");
|
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 / 客户资料已在审核中");
|
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
|
||||||
}
|
}
|
||||||
var qualifications = profileRepository.findQualifications(tenantId);
|
var qualifications = profileRepository.findQualifications(tenantId);
|
||||||
boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()));
|
if (!hasAllPendingQualifications(qualifications)) {
|
||||||
boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()));
|
throw new BizException(ResultCode.BAD_REQUEST,
|
||||||
if (!hasLicense || !hasLegalId) {
|
"Business license, legal-person ID, freight-forwarder certificate and bank account license are required / 营业执照、法人身份证、货代资质证书和银行开户许可证均为必填资质");
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "Business license and legal-person ID are required / 营业执照和法人身份证为必填资质");
|
|
||||||
}
|
}
|
||||||
IamTenantReview review = new IamTenantReview();
|
IamTenantReview review = new IamTenantReview();
|
||||||
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
|
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
|
||||||
@@ -250,6 +273,11 @@ public class TenantService {
|
|||||||
return tenant.getId() != null && tenant.getId().longValue() == id;
|
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) {
|
private IamTenant requireMerchant(long tenantId) {
|
||||||
IamTenant tenant = repository.findTenant(tenantId);
|
IamTenant tenant = repository.findTenant(tenantId);
|
||||||
if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
|
if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||||
@@ -258,6 +286,14 @@ public class TenantService {
|
|||||||
return tenant;
|
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) {
|
private TenantResponse response(IamTenant tenant) {
|
||||||
return new TenantResponse(
|
return new TenantResponse(
|
||||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
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),
|
(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),
|
(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),
|
(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),
|
(5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
|
||||||
-- Platform: buttons.
|
-- Platform: buttons.
|
||||||
(5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0),
|
(5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0),
|
||||||
(5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0),
|
(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),
|
(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),
|
(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),
|
(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
|
SELECT 4000002, id, 0 FROM iam_permission
|
||||||
WHERE permission_code IN (
|
WHERE permission_code IN (
|
||||||
'platform:customer', 'platform:customer:list', 'platform:customer:review',
|
'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);
|
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||||
|
|
||||||
|
|||||||
+28
-2
@@ -18,6 +18,16 @@ UPDATE iam_permission
|
|||||||
SET built_in = 1
|
SET built_in = 1
|
||||||
WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0;
|
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
|
INSERT INTO iam_permission
|
||||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
|
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
|
||||||
sort_no, status, built_in, deleted)
|
sort_no, status, built_in, deleted)
|
||||||
@@ -25,6 +35,7 @@ VALUES
|
|||||||
(5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0),
|
(5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0),
|
||||||
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, '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),
|
(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),
|
(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),
|
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
|
||||||
(5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, '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),
|
(5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0),
|
||||||
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, '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),
|
(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
|
ON DUPLICATE KEY UPDATE
|
||||||
permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE',
|
permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE',
|
||||||
built_in = 1, deleted = 0;
|
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:customer:edit', 'platform:customer:status', 'platform:user:grant-role',
|
||||||
'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
|
'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: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);
|
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)
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||||
SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id
|
SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id
|
||||||
|
|||||||
@@ -464,6 +464,16 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200': {$ref: '#/components/responses/Success'}
|
'200': {$ref: '#/components/responses/Success'}
|
||||||
'403': {$ref: '#/components/responses/Forbidden'}
|
'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:
|
/api/{app}/v1/iam/roles:
|
||||||
parameters:
|
parameters:
|
||||||
- {$ref: '#/components/parameters/App'}
|
- {$ref: '#/components/parameters/App'}
|
||||||
@@ -749,7 +759,7 @@ components:
|
|||||||
documentUrl: {type: string}
|
documentUrl: {type: string}
|
||||||
validFrom: {type: string, format: date}
|
validFrom: {type: string, format: date}
|
||||||
validTo: {type: string, format: date, nullable: true}
|
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}
|
reviewComment: {type: string}
|
||||||
RegisterRequest:
|
RegisterRequest:
|
||||||
type: object
|
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(pathContract.contains("\n post:"));
|
||||||
assertFalse(yaml.contains(" QualificationRequest:"));
|
assertFalse(yaml.contains(" QualificationRequest:"));
|
||||||
|
assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -48,9 +48,11 @@ class CustomerProfileMigrationContractTest {
|
|||||||
.contains("CALL regression_assert_table('iam_tenant')")
|
.contains("CALL regression_assert_table('iam_tenant')")
|
||||||
.contains("CREATE PROCEDURE regression_add_iam_column")
|
.contains("CREATE PROCEDURE regression_add_iam_column")
|
||||||
.contains("platform:customer:view")
|
.contains("platform:customer:view")
|
||||||
|
.contains("platform:customer:export")
|
||||||
|
.contains("platform:download:list")
|
||||||
.contains("'IAM客户档案字段' AS check_item")
|
.contains("'IAM客户档案字段' AS check_item")
|
||||||
.contains("9 AS expected_count")
|
.contains("9 AS expected_count")
|
||||||
.contains("44 AS expected_count")
|
.contains("47 AS expected_count")
|
||||||
.contains("MIGRATION_COMPLETED");
|
.contains("MIGRATION_COMPLETED");
|
||||||
assertThat(instructions)
|
assertThat(instructions)
|
||||||
.contains("phase1-platform-master-data-and-permission-management.sql")
|
.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);
|
ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository);
|
||||||
IamTenant tenant = new IamTenant();
|
IamTenant tenant = new IamTenant();
|
||||||
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
|
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());
|
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
|
||||||
|
|
||||||
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
||||||
@@ -59,7 +59,7 @@ class PlatformQualificationRegistrationTest {
|
|||||||
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID");
|
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID");
|
||||||
existing.setStatus("PENDING");
|
existing.setStatus("PENDING");
|
||||||
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
|
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));
|
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
|
||||||
|
|
||||||
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
||||||
@@ -68,7 +68,7 @@ class PlatformQualificationRegistrationTest {
|
|||||||
|
|
||||||
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
|
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
|
||||||
verify(repository, times(2)).saveQualification(captor.capture());
|
verify(repository, times(2)).saveQualification(captor.capture());
|
||||||
assertEquals("REJECTED", existing.getStatus());
|
assertEquals("SUPERSEDED", existing.getStatus());
|
||||||
assertEquals("平台在送审前替换文件", existing.getReviewComment());
|
assertEquals("平台在送审前替换文件", existing.getReviewComment());
|
||||||
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
|
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
|
||||||
assertEquals("PENDING", captor.getAllValues().get(1).getStatus());
|
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
|
@Test
|
||||||
void explicitSubmissionRequiresBothMandatoryQualificationsAndCreatesReview() {
|
void explicitSubmissionRequiresAllPrototypeQualificationsAndCreatesReview() {
|
||||||
TenantRepository repository = mock(TenantRepository.class);
|
TenantRepository repository = mock(TenantRepository.class);
|
||||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||||
TenantService service = new TenantService();
|
TenantService service = new TenantService();
|
||||||
ReflectionTestUtils.setField(service, "repository", repository);
|
ReflectionTestUtils.setField(service, "repository", repository);
|
||||||
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
||||||
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
|
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(
|
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);
|
var submitted = service.submitMerchantReview(10001L);
|
||||||
|
|
||||||
@@ -143,6 +144,23 @@ class TenantServiceTest {
|
|||||||
verify(repository).saveTenant(tenant);
|
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
|
@Test
|
||||||
void 企业激活不等于具备下单资格仍需已审核资质() {
|
void 企业激活不等于具备下单资格仍需已审核资质() {
|
||||||
TenantRepository repository = mock(TenantRepository.class);
|
TenantRepository repository = mock(TenantRepository.class);
|
||||||
|
|||||||
Reference in New Issue
Block a user