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