Merge remote-tracking branch 'origin/test' into test-01

This commit is contained in:
2026-08-04 14:55:52 +08:00
29 changed files with 664 additions and 178 deletions
@@ -47,7 +47,7 @@ public class AuthController {
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @Operation(summary = "Merchant registration / 客户注册",
description = "Create a login account and an enterprise profile awaiting four required qualification files.") description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) { public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request)); return ApiResponse.success(registrationService.register(request));
} }
+1
View File
@@ -12,6 +12,7 @@
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 模块使用、RocketMQ 部署与导出排查 | `README.md`;完整架构与接口契约见 `../docs/公共导出与下载中心接入说明.md` |
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` | | 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` |
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` | | 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
| 服务状态 | `controller/FileHealthController.java` | | 服务状态 | `controller/FileHealthController.java` |
+125
View File
@@ -0,0 +1,125 @@
# File Service 使用与运维说明
File Service 同时承载企业资质文件和公共导出下载中心。公共导出采用数据库任务表、事务 Outbox、RocketMQ 和业务 Provider;完整架构与接口契约见 [`../docs/公共导出与下载中心接入说明.md`](../docs/公共导出与下载中心接入说明.md)。
## 公共导出运行条件
创建导出任务只会写入 `file_export_task` 和 `file_export_outbox`。以下三个组件全部启用后,任务才会从 `PENDING` 继续执行:
```dotenv
FILE_EXPORT_RECONCILER_ENABLED=true
FILE_EXPORT_PUBLISHER_ENABLED=true
FILE_EXPORT_CONSUMER_ENABLED=true
FILE_EXPORT_CONSUMER_CONCURRENCY=4
```
三个开关默认均为 `false`,避免 RocketMQ 尚未就绪时 File Service 启动失败。Docker 容器只在创建时读取环境变量;修改 Jenkins 环境文件后必须重新部署 File Service,`docker restart` 不会更新配置。
当前测试环境由 Jenkins 文件凭据 `firstleg-test-env` 注入这些变量;凭据内容变更后重新执行 `file` 服务的部署阶段。
测试环境还必须配置:
```dotenv
INFRA_NETWORK=firstleg-test-infra-network
ROCKETMQ_NAME_SERVER=rocketmq-namesrv:9876
ROCKETMQ_BROKER_IP=rocketmq-broker
MQ_TOPIC_PREFIX=test-
ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store
```
File 与 RocketMQ 不暴露 MQ 宿主机端口,二者通过 `infra-network` 和 Docker 服务名通信。`ROCKETMQ_BROKER_IP` 不得填写 `localhost`、`127.0.0.1` 或宿主机公网地址。
## 测试环境首次部署 RocketMQ
在服务器的稳定仓库目录执行,不要依赖会被 Jenkins 清理的临时工作区:
```bash
cd /www/deploy/baiyue-firstleg
mkdir -p /data/firstleg/rocketmq/store
chown -R 3000:3000 /data/firstleg/rocketmq/store
DEPLOY_ENV=test \
INFRA_NETWORK=firstleg-test-infra-network \
ROCKETMQ_BROKER_IP=rocketmq-broker \
ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store \
MQ_TOPIC_PREFIX=test- \
docker compose -f deploy/docker-compose.rocketmq.yml up -d
```
官方 `apache/rocketmq:5.3.1` 镜像使用 UID/GID `3000` 的 `rocketmq` 用户。宿主机存储目录若属于 `root:root` 且不可写,Broker 会反复重启;不要把 Broker 改成 root 用户,应修正挂载目录所有权。
部署后的正常状态:
```text
rocketmq-namesrv Up
rocketmq-broker Up
rocketmq-topic-init Exited (0)
```
`rocketmq-topic-init` 是一次性初始化容器,`Exited (0)` 表示 Topic、消费组和 DLQ 初始化成功。
验证 Broker 注册结果:
```bash
docker run --rm \
--network firstleg-test-infra-network \
apache/rocketmq:5.3.1 \
sh mqadmin clusterList -n rocketmq-namesrv:9876
```
输出必须包含 `BaiyueCluster`、`baiyue-file-export-broker` 和可访问地址 `rocketmq-broker:10911`。
## 分阶段启用导出
首次上线按以下顺序逐步修改环境文件,每一步都重新部署 File Service 并确认容器 `healthy`、`restartCount=0`:
1. Reconciler:`true / false / false`
2. Reconciler + Publisher:`true / true / false`
3. Reconciler + Publisher + Consumer:`true / true / true`
顺序均为 `RECONCILER / PUBLISHER / CONSUMER`。第二阶段应观察到 Outbox 从 `PENDING` 变为 `SENT`;第三阶段任务应从 `PENDING` 进入 `RUNNING`,最终进入 `SUCCEEDED` 或带明确原因的 `FAILED`。已有的 `PENDING` 任务无需重建或手工改状态。
## 快速排查
任务状态查询:
```sql
SELECT id, task_no, status, dispatch_version, attempt_count,
processed_rows, next_retry_at, lease_owner, lease_until,
requested_at, started_at, completed_at, failure_message
FROM file_export_task
WHERE id = ?;
```
Outbox 查询:
```sql
SELECT id, task_id, dispatch_version, topic, status, attempt_count,
next_retry_at, lease_owner, lease_until, sent_at, last_error
FROM file_export_outbox
WHERE task_id = ?
ORDER BY id;
```
| 现象 | 判断与处理 |
|---|---|
| Task `PENDING`、`attempt_count=0`,Outbox `PENDING`、`attempt_count=0` | Publisher 未启用或未运行 |
| Outbox `PENDING` 且有 `last_error` | Publisher 已尝试,检查 NameServer、Broker、Topic 和网络 |
| Outbox `CLAIMED` 且租约过期 | Publisher 中断,确认 Reconciler 已启用 |
| Outbox `SENT`,Task 仍为 `PENDING` | Consumer 未启用、未连接或存在消费积压 |
| File 启动时报 `connect to null failed` | NameServer/Broker 未运行,或未连接同一 `infra-network` |
| Broker 反复重启,数据目录为 `root:root` | 将挂载目录递归调整为 `3000:3000`,不要使用 root 运行 Broker |
不要手工把 Task 改为 `RUNNING` 或把 Outbox 改为 `SENT`,否则会绕过租约、幂等和消息投递链路。
## 验收
测试环境主链路验收使用:
```powershell
$env:ADMIN_ACCESS_TOKEN = '<测试环境平台用户Token>'
.\scripts\export-center-test-env-acceptance.ps1 `
-GatewayBaseUrl 'http://8.163.30.128:9000'
```
脚本会创建真实 `CUSTOMER_LIST` 任务、轮询终态、下载文件并检查 XLSX ZIP 签名。Token 只通过当前进程环境变量注入,不写入仓库文件。
+7 -7
View File
@@ -16,7 +16,7 @@
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` | | 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
| 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 | | 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 |
| 平台统一用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;统一维护平台与企业租户账号,选项接口返回租户和角色 | | 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台租户和 PLATFORM 角色 |
| 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 | | 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 |
| 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 | | 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 |
| 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` |
@@ -26,16 +26,16 @@
| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 | | 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 |
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 | | 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
| OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 | | OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 |
| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、统一用户、平台资质和 RBAC 测试 | | 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、内部用户、平台资质和 RBAC 测试 |
## 修改与验证 ## 修改与验证
- `/api/platform/v1/iam/users` 是后台“用户管理”的统一入口,覆盖 `tenant_id=0` 的平台账号和企业租户成员;`/iam/internal-users` 是较早的平台内部用户专用入口,定位问题时不要混用 Controller/Service。 - `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业租户成员必须走商户成员边界。
- 统一用户创建要求 `platform:user:create` 与 `platform:user:grant-role`;跨租户或修改角色还需授角权限。角色必须属于目标租户和对应 `PLATFORM/MERCHANT` appCode,受保护的平台管理员不得被降权、停用或自停用。 - 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台租户且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。
- 跨租户移动用户时必须同步成员租户、角色和 Auth 账号 scope;协作入口为 `client/AuthAccountClient.java` 与 Auth 的 `PUT /internal/auth/accounts/{id}/scope`,任一步失败都应回滚本地事务或执行补偿。 - `/users` 不提供跨租户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。 - 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。 - 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。 - 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。
- 企业送审和审核通过都要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID`、`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 四类资质均为 `PENDING`;审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。 - 企业送审和审核通过只要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID` 两类必填资质均为 `PENDING`;`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 为选填,已上传时仍随审核统一处理。审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。
- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。 - 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。 - 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
@@ -47,7 +47,7 @@
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。 - 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。 - 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前租户。 - 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前租户。
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须四类必填资质全部为 `APPROVED`。任意一类缺失、驳回、已替换或过期都必须由服务端阻止提交订单。 - 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须营业执照和法人身份证两类必填资质均为 `APPROVED`。任一必填资质缺失、驳回、已替换或过期都必须由服务端阻止提交订单;选填资质不影响下单资格。
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。 - SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。 - 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。 - `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
+3 -3
View File
@@ -20,9 +20,9 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe
| GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 | | GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 |
| POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 |
| POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 |
| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Unified platform and merchant user administration / 统一用户管理 | | GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Paginated platform-internal user administration (`tenant_id=0`) / 平台内部用户分页管理 |
| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Tenant and role options / 用户租户与角色选项 | | GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform tenant and PLATFORM role options / 平台租户与平台角色选项 |
| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit profile, tenant and roles / 编辑用户资料、租户和角色 | | PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit internal-user profile and roles / 编辑内部用户资料和角色 |
| PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 | | PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 |
| PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 | | PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 |
| GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 | | GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 |
@@ -72,7 +72,7 @@ public class MerchantTenantController {
@PostMapping("/tenant/qualifications/submit-review") @PostMapping("/tenant/qualifications/submit-review")
@PreAuthorize("hasAuthority('merchant:qualification:upload')") @PreAuthorize("hasAuthority('merchant:qualification:upload')")
@Operation(summary = "提交当前企业资质审核", description = "校验四类必填资质齐全后创建审核单。上传或替换文件不会自动送审。") @Operation(summary = "提交当前企业资质审核", description = "校验营业执照和法人身份证齐全后创建审核单;货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。")
public ApiResponse<TenantResponse> submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) { public ApiResponse<TenantResponse> submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT"))); return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
} }
@@ -1,6 +1,7 @@
package com.baiyue.iam.controller; package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.service.PlatformUserManagementService; import com.baiyue.iam.service.PlatformUserManagementService;
@@ -8,15 +9,18 @@ import com.baiyue.iam.service.TenantGuard;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; /** Admin 内部用户管理入口,只允许维护平台租户账号。 */
/** 原型“用户管理”的平台统一入口,覆盖平台与企业租户账号。 */
@RestController @RestController
@RequestMapping("/api/platform/v1/iam/users") @RequestMapping("/api/platform/v1/iam/users")
@Validated
@Tag(name = "Platform user administration / 平台用户管理") @Tag(name = "Platform user administration / 平台用户管理")
public class PlatformUserManagementController { public class PlatformUserManagementController {
private final PlatformUserManagementService service; private final PlatformUserManagementService service;
@@ -27,15 +31,20 @@ public class PlatformUserManagementController {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:user:list')") @PreAuthorize("hasAuthority('platform:user:list')")
@Operation(summary = "统一查询平台及企业租户用户") @Operation(summary = "分页查询平台内部用户")
public ApiResponse<List<UserResponse>> list(@AuthenticationPrincipal AuthenticatedUser user) { public ApiResponse<PageResult<UserResponse>> list(
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.list()); return ApiResponse.success(service.page(page, size, keyword, status));
} }
@GetMapping("/options") @GetMapping("/options")
@PreAuthorize("hasAuthority('platform:user:list')") @PreAuthorize("hasAuthority('platform:user:list')")
@Operation(summary = "查询用户管理可选租户和角色") @Operation(summary = "查询平台内部用户可选平台角色")
public ApiResponse<UserOptions> options(@AuthenticationPrincipal AuthenticatedUser user) { public ApiResponse<UserOptions> options(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.options()); return ApiResponse.success(service.options());
@@ -43,7 +52,7 @@ public class PlatformUserManagementController {
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')") @PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')")
@Operation(summary = "新增平台或企业租户用户") @Operation(summary = "新增平台内部用户")
public ApiResponse<UserResponse> create(@Valid @RequestBody CreateUserRequest request, public ApiResponse<UserResponse> create(@Valid @RequestBody CreateUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
@@ -52,7 +61,7 @@ public class PlatformUserManagementController {
@PutMapping("/{membershipId:\\d+}") @PutMapping("/{membershipId:\\d+}")
@PreAuthorize("hasAuthority('platform:user:edit')") @PreAuthorize("hasAuthority('platform:user:edit')")
@Operation(summary = "编辑用户资料、租户、角色、状态和密码") @Operation(summary = "编辑平台内部用户资料和角色")
public ApiResponse<UserResponse> update(@PathVariable Long membershipId, public ApiResponse<UserResponse> update(@PathVariable Long membershipId,
@Valid @RequestBody UpdateUserRequest request, @Valid @RequestBody UpdateUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
@@ -9,7 +9,7 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** 平台统一用户管理契约,覆盖平台账号与企业租户账号。 */ /** Admin 平台内部用户管理契约;tenantId 保留用于兼容既有请求,但只能为 0。 */
public final class PlatformUserManagementDtos { public final class PlatformUserManagementDtos {
private PlatformUserManagementDtos() { } private PlatformUserManagementDtos() { }
@@ -1,5 +1,6 @@
package com.baiyue.iam.repository; package com.baiyue.iam.repository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamMembershipRole; import com.baiyue.iam.entity.IamMembershipRole;
import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.entity.IamUser;
@@ -8,7 +9,9 @@ import com.baiyue.iam.mapper.IamMembershipRoleMapper;
import com.baiyue.iam.mapper.IamUserMapper; import com.baiyue.iam.mapper.IamUserMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List; import java.util.List;
@@ -30,9 +33,27 @@ public class MemberRepository {
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>() return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt)); .eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt));
} }
public List<IamMembership> findAll() { public PageResult<IamMembership> pageByTenant(long tenantId, long page, long size,
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>() String keyword, String status) {
.orderByDesc(IamMembership::getJoinedAt)); LambdaQueryWrapper<IamMembership> wrapper = new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId)
.eq(StringUtils.hasText(status), IamMembership::getStatus, status)
.orderByDesc(IamMembership::getJoinedAt);
if (StringUtils.hasText(keyword)) {
String normalizedKeyword = keyword.trim();
List<Long> matchingUserIds = userMapper.selectList(new LambdaQueryWrapper<IamUser>()
.select(IamUser::getId)
.in(IamUser::getUserType, "INTERNAL", "PLATFORM")
.and(query -> query.like(IamUser::getDisplayName, normalizedKeyword)
.or().like(IamUser::getPhone, normalizedKeyword)
.or().like(IamUser::getEmail, normalizedKeyword)))
.stream().map(IamUser::getId).toList();
wrapper.and(query -> {
query.like(IamMembership::getMemberNo, normalizedKeyword);
if (!matchingUserIds.isEmpty()) query.or().in(IamMembership::getUserId, matchingUserIds);
});
}
return PageResult.from(membershipMapper.selectPage(new Page<>(page, size), wrapper));
} }
public IamMembership findById(Long id) { return membershipMapper.selectById(id); } public IamMembership findById(Long id) { return membershipMapper.selectById(id); }
public IamMembership findByTenantAndId(long tenantId, Long id) { public IamMembership findByTenantAndId(long tenantId, Long id) {
@@ -2,17 +2,16 @@ package com.baiyue.iam.service;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.TenantRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException; import feign.FeignException;
@@ -31,45 +30,41 @@ import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** 平台管理员跨租户维护登录用户;凭据仍只由 auth-service 保存。 */ /** 平台管理员维护内部登录用户;凭据仍只由 auth-service 保存。 */
@Service @Service
public class PlatformUserManagementService { public class PlatformUserManagementService {
private static final long PLATFORM_TENANT_ID = 0L; private static final long PLATFORM_TENANT_ID = 0L;
private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class); private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class);
private final MemberRepository members; private final MemberRepository members;
private final TenantRepository tenants;
private final IamRoleMapper roleMapper; private final IamRoleMapper roleMapper;
private final AuthAccountClient authClient; private final AuthAccountClient authClient;
private final String internalToken; private final String internalToken;
public PlatformUserManagementService(MemberRepository members, TenantRepository tenants, public PlatformUserManagementService(MemberRepository members, IamRoleMapper roleMapper,
IamRoleMapper roleMapper, AuthAccountClient authClient, AuthAccountClient authClient,
@Value("${baiyue.security.internal-service-token}") String internalToken) { @Value("${baiyue.security.internal-service-token}") String internalToken) {
this.members = members; this.members = members;
this.tenants = tenants;
this.roleMapper = roleMapper; this.roleMapper = roleMapper;
this.authClient = authClient; this.authClient = authClient;
this.internalToken = internalToken; this.internalToken = internalToken;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<UserResponse> list() { public PageResult<UserResponse> page(long page, long size, String keyword, String status) {
Map<Long, IamTenant> tenantMap = tenants.listMerchants().stream() PageResult<IamMembership> source = members.pageByTenant(
.collect(Collectors.toMap(IamTenant::getId, Function.identity())); PLATFORM_TENANT_ID, page, size, keyword, status);
Map<Long, IamRole> roleMap = allRoles().stream() Map<Long, IamRole> roleMap = platformRoles().stream()
.collect(Collectors.toMap(IamRole::getId, Function.identity())); .collect(Collectors.toMap(IamRole::getId, Function.identity()));
return members.findAll().stream().map(membership -> response(membership, tenantMap, roleMap)).toList(); return new PageResult<>(source.records().stream()
.map(membership -> response(membership, roleMap)).toList(),
source.page(), source.size(), source.total(), source.pages());
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public UserOptions options() { public UserOptions options() {
List<TenantOption> tenantOptions = new java.util.ArrayList<>(); List<TenantOption> tenantOptions = List.of(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE"));
tenantOptions.add(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE")); List<RoleOption> roleOptions = platformRoles().stream()
tenants.listMerchants().stream()
.map(value -> new TenantOption(value.getId(), value.getTenantName(), value.getStatus()))
.forEach(tenantOptions::add);
List<RoleOption> roleOptions = allRoles().stream()
.map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus())) .map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus()))
.toList(); .toList();
return new UserOptions(List.copyOf(tenantOptions), roleOptions); return new UserOptions(List.copyOf(tenantOptions), roleOptions);
@@ -80,7 +75,7 @@ public class PlatformUserManagementService {
resourceId = "#request.username", description = "平台新增用户", captureAfter = false) resourceId = "#request.username", description = "平台新增用户", captureAfter = false)
public UserResponse create(CreateUserRequest request) { public UserResponse create(CreateUserRequest request) {
long tenantId = request.tenantId(); long tenantId = request.tenantId();
validateTenant(tenantId); requirePlatformTenant(tenantId);
List<Long> roleIds = validateRoles(tenantId, request.roleIds()); List<Long> roleIds = validateRoles(tenantId, request.roleIds());
long authUserId = IdWorker.getId(); long authUserId = IdWorker.getId();
long userId = IdWorker.getId(); long userId = IdWorker.getId();
@@ -122,11 +117,11 @@ public class PlatformUserManagementService {
@BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP", @BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false) resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false)
public UserResponse update(Long membershipId, UpdateUserRequest request, Set<String> operatorPermissions) { public UserResponse update(Long membershipId, UpdateUserRequest request, Set<String> operatorPermissions) {
IamMembership membership = required(membershipId); IamMembership membership = requiredPlatformMembership(membershipId);
IamUser user = requiredUser(membership.getUserId()); IamUser user = requiredUser(membership.getUserId());
long oldTenantId = membership.getTenantId(); long oldTenantId = membership.getTenantId();
long newTenantId = request.tenantId(); long newTenantId = request.tenantId();
validateTenant(newTenantId); requirePlatformTenant(newTenantId);
List<Long> roleIds = validateRoles(newTenantId, request.roleIds()); List<Long> roleIds = validateRoles(newTenantId, request.roleIds());
List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId); List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId);
if (isProtectedPlatformAdmin(membership, oldRoleIds) if (isProtectedPlatformAdmin(membership, oldRoleIds)
@@ -170,7 +165,7 @@ public class PlatformUserManagementService {
@BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP", @BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台启停用户", captureAfter = false) resourceId = "#membershipId", description = "平台启停用户", captureAfter = false)
public UserResponse changeStatus(Long membershipId, ChangeStatusRequest request, Long operatorMembershipId) { public UserResponse changeStatus(Long membershipId, ChangeStatusRequest request, Long operatorMembershipId) {
IamMembership membership = required(membershipId); IamMembership membership = requiredPlatformMembership(membershipId);
if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) { if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) {
throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户"); throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户");
} }
@@ -200,39 +195,35 @@ public class PlatformUserManagementService {
@BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP", @BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false) resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false)
public void resetPassword(Long membershipId, ResetPasswordRequest request) { public void resetPassword(Long membershipId, ResetPasswordRequest request) {
IamUser user = requiredUser(required(membershipId).getUserId()); IamUser user = requiredUser(requiredPlatformMembership(membershipId).getUserId());
call(() -> authClient.reset(internalToken, user.getAuthUserId(), call(() -> authClient.reset(internalToken, user.getAuthUserId(),
new AuthAccountClient.PasswordRequest(request.newPassword()))); new AuthAccountClient.PasswordRequest(request.newPassword())));
} }
private UserResponse response(IamMembership membership) { private UserResponse response(IamMembership membership) {
Map<Long, IamTenant> tenantMap = tenants.listMerchants().stream() Map<Long, IamRole> roleMap = platformRoles().stream()
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
Map<Long, IamRole> roleMap = allRoles().stream()
.collect(Collectors.toMap(IamRole::getId, Function.identity())); .collect(Collectors.toMap(IamRole::getId, Function.identity()));
return response(membership, tenantMap, roleMap); return response(membership, roleMap);
} }
private UserResponse response(IamMembership membership, Map<Long, IamTenant> tenantMap, private UserResponse response(IamMembership membership, Map<Long, IamRole> roleMap) {
Map<Long, IamRole> roleMap) {
IamUser user = requiredUser(membership.getUserId()); IamUser user = requiredUser(membership.getUserId());
AuthAccountClient.AccountSummary account = account(user.getAuthUserId()); AuthAccountClient.AccountSummary account = account(user.getAuthUserId());
List<Long> roleIds = members.findRoleIds(membership.getTenantId(), membership.getId()); List<Long> roleIds = members.findRoleIds(membership.getTenantId(), membership.getId());
List<String> roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull) List<String> roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull)
.map(IamRole::getRoleName).toList(); .map(IamRole::getRoleName).toList();
String tenantName = membership.getTenantId() == PLATFORM_TENANT_ID ? "平台" String tenantName = "平台";
: tenantMap.containsKey(membership.getTenantId())
? tenantMap.get(membership.getTenantId()).getTenantName() : "未知租户";
return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(),
user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName,
membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds,
roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds));
} }
private List<IamRole> allRoles() { private List<IamRole> platformRoles() {
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>() return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
.in(IamRole::getAppCode, "PLATFORM", "MERCHANT") .eq(IamRole::getTenantId, PLATFORM_TENANT_ID)
.orderByAsc(IamRole::getTenantId).orderByAsc(IamRole::getRoleName)); .eq(IamRole::getAppCode, "PLATFORM")
.orderByAsc(IamRole::getRoleName));
} }
private List<Long> validateRoles(long tenantId, List<Long> requested) { private List<Long> validateRoles(long tenantId, List<Long> requested) {
@@ -257,11 +248,9 @@ public class PlatformUserManagementService {
.anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode())); .anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode()));
} }
private void validateTenant(long tenantId) { private void requirePlatformTenant(long tenantId) {
if (tenantId == PLATFORM_TENANT_ID) return; if (tenantId != PLATFORM_TENANT_ID) {
IamTenant tenant = tenants.findTenant(tenantId); throw new BizException(ResultCode.FORBIDDEN, "内部用户只能属于平台租户");
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType()) || !"ACTIVE".equals(tenant.getStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "只能选择正常状态的企业租户");
} }
} }
@@ -271,6 +260,14 @@ public class PlatformUserManagementService {
return membership; return membership;
} }
private IamMembership requiredPlatformMembership(Long membershipId) {
IamMembership membership = required(membershipId);
if (membership.getTenantId() != PLATFORM_TENANT_ID) {
throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在");
}
return membership;
}
private IamUser requiredUser(Long userId) { private IamUser requiredUser(Long userId) {
IamUser user = members.findUser(userId); IamUser user = members.findUser(userId);
if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在"); if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在");
@@ -296,7 +293,7 @@ public class PlatformUserManagementService {
try { try {
call(() -> authClient.compensate(internalToken, authUserId)); call(() -> authClient.compensate(internalToken, authUserId));
} catch (RuntimeException compensationFailure) { } catch (RuntimeException compensationFailure) {
log.error("统一用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); log.error("内部用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); "用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
} }
@@ -311,7 +308,7 @@ public class PlatformUserManagementService {
if (profileChanged) call(() -> authClient.profile(internalToken, authUserId, if (profileChanged) call(() -> authClient.profile(internalToken, authUserId,
new AuthAccountClient.ProfileRequest(oldAccount.displayName()))); new AuthAccountClient.ProfileRequest(oldAccount.displayName())));
} catch (RuntimeException compensationFailure) { } catch (RuntimeException compensationFailure) {
log.error("统一用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); log.error("内部用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); "用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
} }
@@ -35,7 +35,7 @@ import java.util.Set;
@Service @Service
public class TenantService { public class TenantService {
private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of( private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of(
"BUSINESS_LICENSE", "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE"); "BUSINESS_LICENSE", "LEGAL_PERSON_ID");
@Resource @Resource
private TenantRepository repository; private TenantRepository repository;
@Resource @Resource
@@ -273,7 +273,7 @@ public class TenantService {
var qualifications = profileRepository.findQualifications(tenantId); var qualifications = profileRepository.findQualifications(tenantId);
if (!hasAllPendingQualifications(qualifications)) { if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.BAD_REQUEST, throw new BizException(ResultCode.BAD_REQUEST,
"Business license, legal-person ID, freight-forwarder certificate and bank account license are required / 营业执照、法人身份证、货代资质证书和银行开户许可证均为必填资质"); "Business license and legal-person ID are required / 营业执照和法人身份证为必填资质");
} }
IamTenantReview review = new IamTenantReview(); IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenantId); review.setId(IdWorker.getId()); review.setTenantId(tenantId);
+53 -10
View File
@@ -210,13 +210,22 @@ paths:
/api/platform/v1/iam/users: /api/platform/v1/iam/users:
get: get:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 统一查询平台及企业租户用户 summary: 分页查询平台内部用户
parameters:
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
- {name: keyword, in: query, schema: {type: string}, description: 匹配姓名、手机号、邮箱或成员编号}
- {name: status, in: query, schema: {type: string, enum: [ACTIVE, DISABLED]}}
responses: responses:
'200': {$ref: '#/components/responses/Success'} '200':
description: 仅返回 tenant_id=0 的平台内部用户分页
content:
application/json:
schema: {$ref: '#/components/schemas/PlatformManagedUserPageApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
post: post:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 新增平台或企业租户用户 summary: 新增平台内部用户
requestBody: requestBody:
required: true required: true
content: content:
@@ -228,7 +237,7 @@ paths:
/api/platform/v1/iam/users/options: /api/platform/v1/iam/users/options:
get: get:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 查询用户管理可选租户和角色 summary: 查询平台租户和 PLATFORM 角色选项
responses: responses:
'200': {$ref: '#/components/responses/Success'} '200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
@@ -237,7 +246,7 @@ paths:
- {$ref: '#/components/parameters/MembershipId'} - {$ref: '#/components/parameters/MembershipId'}
put: put:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 编辑用户资料、所属租户及角色 summary: 编辑平台内部用户资料及角色
requestBody: requestBody:
required: true required: true
content: content:
@@ -251,7 +260,7 @@ paths:
- {$ref: '#/components/parameters/MembershipId'} - {$ref: '#/components/parameters/MembershipId'}
put: put:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 启用或停用平台及企业租户用户 summary: 启用或停用平台内部用户
requestBody: requestBody:
required: true required: true
content: content:
@@ -265,7 +274,7 @@ paths:
- {$ref: '#/components/parameters/MembershipId'} - {$ref: '#/components/parameters/MembershipId'}
put: put:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 重置平台及企业租户用户密码 summary: 重置平台内部用户密码
requestBody: requestBody:
required: true required: true
content: content:
@@ -472,7 +481,7 @@ paths:
post: post:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: Submit enterprise qualifications for review / 提交企业资质审核 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. / 上传或替换资质不会自动送审;四类必填资质齐全后由此接口创建审核单。 description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review after the business license and legal-person ID have been provided; the freight-forwarder certificate and bank account license are optional. / 上传或替换资质不会自动送审;营业执照和法人身份证齐全后由此接口创建审核单,货代资质证书和银行开户许可证选填。
responses: responses:
'200': {$ref: '#/components/responses/TenantSuccess'} '200': {$ref: '#/components/responses/TenantSuccess'}
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
@@ -748,6 +757,20 @@ components:
- type: object - type: object
properties: properties:
data: {$ref: '#/components/schemas/InternalUserResponse'} data: {$ref: '#/components/schemas/InternalUserResponse'}
PlatformManagedUserPageApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/PlatformManagedUserResponse'}}
page: {type: integer, minimum: 1}
size: {type: integer, minimum: 1}
total: {type: integer, minimum: 0}
pages: {type: integer, minimum: 0}
TenantResponse: TenantResponse:
type: object type: object
required: [id, tenantCode, tenantName, status, servicePackage] required: [id, tenantCode, tenantName, status, servicePackage]
@@ -883,7 +906,7 @@ components:
displayName: {type: string, maxLength: 64} displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'} phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128} email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
status: {type: string, enum: [ACTIVE, DISABLED]} status: {type: string, enum: [ACTIVE, DISABLED]}
@@ -894,7 +917,7 @@ components:
displayName: {type: string, maxLength: 64} displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'} phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128} email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
CreateInternalUserRequest: CreateInternalUserRequest:
type: object type: object
@@ -948,6 +971,26 @@ components:
memberNo: {type: string} memberNo: {type: string}
status: {type: string, enum: [ACTIVE, DISABLED]} status: {type: string, enum: [ACTIVE, DISABLED]}
roleIds: {type: array, items: {type: integer, format: int64}} roleIds: {type: array, items: {type: integer, format: int64}}
PlatformManagedUserResponse:
type: object
required: [membershipId, userId, authUserId, username, displayName, tenantId, tenantName, memberType, status, roleIds, roleNames, protectedAccount]
properties:
membershipId: {type: string, pattern: '^\d+$'}
userId: {type: string, pattern: '^\d+$'}
authUserId: {type: string, pattern: '^\d+$'}
username: {type: string}
displayName: {type: string}
phone: {type: string}
email: {type: string, format: email}
tenantId: {type: string, enum: ['0']}
tenantName: {type: string, enum: [平台]}
memberType: {type: string, enum: [EMPLOYEE]}
memberNo: {type: string}
status: {type: string, enum: [ACTIVE, DISABLED]}
roleIds: {type: array, items: {type: string, pattern: '^\d+$'}}
roleNames: {type: array, items: {type: string}}
lastLoginAt: {type: string, format: date-time, nullable: true}
protectedAccount: {type: boolean}
CreateMemberRequest: CreateMemberRequest:
type: object type: object
required: [username, password, displayName, memberType] required: [username, password, displayName, memberType]
@@ -37,6 +37,7 @@ class PlatformInternalUserContractTest {
@Test @Test
void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception { void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception {
Method list = PlatformUserManagementController.class.getMethod("list", Method list = PlatformUserManagementController.class.getMethod("list",
long.class, long.class, String.class, String.class,
com.baiyue.common.security.AuthenticatedUser.class); com.baiyue.common.security.AuthenticatedUser.class);
Method create = PlatformUserManagementController.class.getMethod("create", Method create = PlatformUserManagementController.class.getMethod("create",
com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class, com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class,
@@ -1,19 +1,17 @@
package com.baiyue.iam.service; package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*; import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.TenantRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -25,110 +23,89 @@ import static org.mockito.Mockito.*;
class PlatformUserManagementServiceTest { class PlatformUserManagementServiceTest {
private final MemberRepository members = mock(MemberRepository.class); private final MemberRepository members = mock(MemberRepository.class);
private final TenantRepository tenants = mock(TenantRepository.class);
private final IamRoleMapper roles = mock(IamRoleMapper.class); private final IamRoleMapper roles = mock(IamRoleMapper.class);
private final AuthAccountClient auth = mock(AuthAccountClient.class); private final AuthAccountClient auth = mock(AuthAccountClient.class);
private final PlatformUserManagementService service = private final PlatformUserManagementService service =
new PlatformUserManagementService(members, tenants, roles, auth, "internal-token"); new PlatformUserManagementService(members, roles, auth, "internal-token");
private IamTenant merchant; private IamRole platformRole;
private IamRole merchantRole;
private IamMembership membership; private IamMembership membership;
private IamUser user; private IamUser user;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
merchant = new IamTenant(); platformRole = new IamRole();
merchant.setId(10001L); merchant.setTenantType("MERCHANT"); merchant.setTenantName("华远贸易"); platformRole.setId(40001L); platformRole.setTenantId(0L); platformRole.setAppCode("PLATFORM");
merchant.setStatus("ACTIVE"); platformRole.setRoleName("平台运营"); platformRole.setStatus("ACTIVE");
merchantRole = new IamRole();
merchantRole.setId(40001L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT");
merchantRole.setRoleName("租户管理员"); merchantRole.setStatus("ACTIVE");
membership = new IamMembership(); membership = new IamMembership();
membership.setId(30L); membership.setTenantId(10001L); membership.setUserId(20L); membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L);
membership.setMemberType("EMPLOYEE"); membership.setMemberNo("MEMBER-30"); membership.setStatus("ACTIVE"); membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30"); membership.setStatus("ACTIVE");
user = new IamUser(); user = new IamUser();
user.setId(20L); user.setAuthUserId(10L); user.setUserType("ENTERPRISE"); user.setId(20L); user.setAuthUserId(10L); user.setUserType("INTERNAL");
user.setDisplayName("张伟"); user.setPhone("13800138000"); user.setStatus("ACTIVE"); user.setDisplayName("张伟"); user.setPhone("13800138000"); user.setStatus("ACTIVE");
when(tenants.findTenant(10001L)).thenReturn(merchant); when(roles.selectById(40001L)).thenReturn(platformRole);
when(tenants.listMerchants()).thenReturn(List.of(merchant)); when(roles.selectList(any())).thenReturn(List.of(platformRole));
when(roles.selectById(40001L)).thenReturn(merchantRole);
when(roles.selectList(any())).thenReturn(List.of(merchantRole));
when(members.findById(30L)).thenReturn(membership); when(members.findById(30L)).thenReturn(membership);
when(members.findUser(20L)).thenReturn(user); when(members.findUser(20L)).thenReturn(user);
when(members.findRoleIds(10001L, 30L)).thenReturn(List.of(40001L)); when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L));
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE",
LocalDateTime.of(2026, 6, 1, 10, 30), 10001L, 30L, "MERCHANT"))); LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM")));
} }
@Test @Test
void listReturnsTenantRoleAndLastLoginForMerchantUser() { void pageReturnsOnlyRepositoryScopedPlatformUsersWithMetadata() {
when(members.findAll()).thenReturn(List.of(membership)); when(members.pageByTenant(0L, 2, 10, "张", "ACTIVE"))
.thenReturn(new PageResult<>(List.of(membership), 2, 10, 21, 3));
UserResponse response = service.list().get(0); PageResult<UserResponse> result = service.page(2, 10, "张", "ACTIVE");
UserResponse response = result.records().get(0);
assertEquals("zhangwei", response.username()); assertEquals("zhangwei", response.username());
assertEquals("华远贸易", response.tenantName()); assertEquals("平台", response.tenantName());
assertEquals(List.of("租户管理员"), response.roleNames()); assertEquals(List.of("平台运营"), response.roleNames());
assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt()); assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt());
assertEquals(21, result.total());
verify(members).pageByTenant(0L, 2, 10, "张", "ACTIVE");
} }
@Test @Test
void createMerchantUserCreatesDisabledMerchantAccountAndMembership() { void createRejectsMerchantTenantBeforeCreatingAuthAccount() {
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED")));
when(auth.summary(anyString(), anyLong())).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED")));
when(members.findUser(anyLong())).thenReturn(user);
CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000",
"new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED"); "new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED");
service.create(request); assertThrows(BizException.class, () -> service.create(request));
verify(auth, never()).create(anyString(), any());
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> account =
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
verify(auth).create(eq("internal-token"), account.capture());
assertEquals("MERCHANT", account.getValue().appCode());
assertEquals("DISABLED", account.getValue().accountStatus());
verify(members).insertUser(argThat(value -> "ENTERPRISE".equals(value.getUserType())));
verify(members).insert(argThat(value -> value.getTenantId() == 10001L
&& "DISABLED".equals(value.getStatus())));
} }
@Test @Test
void tenantMoveUpdatesAuthScopeAndRolesWhenOperatorCanGrantRoles() { void optionsContainOnlyPlatformTenantAndPlatformRoles() {
IamTenant target = new IamTenant(); UserOptions result = service.options();
target.setId(10002L); target.setTenantType("MERCHANT"); target.setTenantName("深圳速达"); target.setStatus("ACTIVE");
IamRole targetRole = new IamRole(); assertEquals(List.of(new TenantOption(0L, "平台", "ACTIVE")), result.tenants());
targetRole.setId(40002L); targetRole.setTenantId(10002L); targetRole.setAppCode("MERCHANT"); assertEquals(1, result.roles().size());
targetRole.setRoleName("操作员"); targetRole.setStatus("ACTIVE"); assertEquals(0L, result.roles().get(0).tenantId());
when(tenants.findTenant(10002L)).thenReturn(target); verifyNoInteractions(members);
when(tenants.listMerchants()).thenReturn(List.of(merchant, target)); }
when(roles.selectById(40002L)).thenReturn(targetRole);
when(roles.selectList(any())).thenReturn(List.of(merchantRole, targetRole)); @Test
when(auth.scope(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null)); void updateRejectsMovingPlatformUserToMerchantTenant() {
when(auth.profile(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null));
UpdateUserRequest request = new UpdateUserRequest("张伟新", "13800138000", null, UpdateUserRequest request = new UpdateUserRequest("张伟新", "13800138000", null,
10002L, List.of(40002L)); 10002L, List.of(40001L));
UserResponse response = service.update(30L, request, Set.of("platform:user:grant-role")); assertThrows(BizException.class,
() -> service.update(30L, request, Set.of("platform:user:grant-role")));
verify(auth).scope(eq("internal-token"), eq(10L), verify(auth, never()).scope(anyString(), anyLong(), any());
argThat(value -> value.tenantId() == 10002L && "MERCHANT".equals(value.appCode())));
verify(members).replaceRoles(10001L, 30L, List.of());
verify(members).replaceRoles(10002L, 30L, List.of(40002L));
assertEquals(10002L, response.tenantId());
} }
@Test @Test
void tenantOrRoleChangeRequiresGrantRolePermission() { void tenantOrRoleChangeRequiresGrantRolePermission() {
IamRole otherRole = new IamRole(); IamRole otherRole = new IamRole();
otherRole.setId(40003L); otherRole.setTenantId(10001L); otherRole.setAppCode("MERCHANT"); otherRole.setId(40003L); otherRole.setTenantId(0L); otherRole.setAppCode("PLATFORM");
otherRole.setRoleName("操作员"); otherRole.setStatus("ACTIVE"); otherRole.setRoleName("操作员"); otherRole.setStatus("ACTIVE");
when(roles.selectById(40003L)).thenReturn(otherRole); when(roles.selectById(40003L)).thenReturn(otherRole);
UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null, UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null,
10001L, List.of(40003L)); 0L, List.of(40003L));
assertThrows(BizException.class, () -> service.update(30L, request, Set.of())); assertThrows(BizException.class, () -> service.update(30L, request, Set.of()));
verify(auth, never()).scope(anyString(), anyLong(), any()); verify(auth, never()).scope(anyString(), anyLong(), any());
@@ -143,7 +120,6 @@ class PlatformUserManagementServiceTest {
@Test @Test
void builtInPlatformAdministratorCannotBeDisabled() { void builtInPlatformAdministratorCannotBeDisabled() {
membership.setTenantId(0L);
IamRole platformAdmin = new IamRole(); IamRole platformAdmin = new IamRole();
platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM"); platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM");
platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员"); platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员");
@@ -155,4 +131,16 @@ class PlatformUserManagementServiceTest {
() -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L)); () -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L));
verify(auth, never()).status(anyString(), anyLong(), any()); verify(auth, never()).status(anyString(), anyLong(), any());
} }
@Test
void merchantMembershipCannotBeChangedThroughInternalUserEndpoints() {
membership.setTenantId(10001L);
assertThrows(BizException.class,
() -> service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L));
assertThrows(BizException.class,
() -> service.resetPassword(30L, new ResetPasswordRequest("Password2")));
verify(auth, never()).status(anyString(), anyLong(), any());
verify(auth, never()).reset(anyString(), anyLong(), any());
}
} }
@@ -134,7 +134,7 @@ class TenantServiceTest {
} }
@Test @Test
void explicitSubmissionRequiresAllPrototypeQualificationsAndCreatesReview() { void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() {
TenantRepository repository = mock(TenantRepository.class); TenantRepository repository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
TenantService service = new TenantService(); TenantService service = new TenantService();
@@ -143,8 +143,7 @@ class TenantServiceTest {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of( when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
var submitted = service.submitMerchantReview(10001L); var submitted = service.submitMerchantReview(10001L);
@@ -162,7 +161,7 @@ class TenantServiceTest {
ReflectionTestUtils.setField(service, "profileRepository", profileRepository); ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED")); when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED"));
when(profileRepository.findQualifications(10001L)).thenReturn(List.of( when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"))); qualification("BUSINESS_LICENSE")));
BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L)); BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
@@ -205,8 +204,7 @@ class TenantServiceTest {
when(repository.findReviewForUpdate(71L)).thenReturn(review); when(repository.findReviewForUpdate(71L)).thenReturn(review);
when(repository.findTenant(10001L)).thenReturn(tenant); when(repository.findTenant(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of( when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
when(provisioning.initialize(any())).thenReturn( when(provisioning.initialize(any())).thenReturn(
TenantAdministratorProvisioningService.ProvisioningResult.created( TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "merchant_10001", "Password1234")); 81L, "merchant_10001", "Password1234"));
@@ -285,7 +283,7 @@ class TenantServiceTest {
} }
@Test @Test
void 企业激活不等于具备下单资格仍需四类资质全部审核通过() { void 企业激活后仅需两类必填资质审核通过即可下单() {
TenantRepository repository = mock(TenantRepository.class); TenantRepository repository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
TenantService service = new TenantService(); TenantService service = new TenantService();
@@ -295,8 +293,7 @@ class TenantServiceTest {
when(profileRepository.findQualifications(10001L)).thenReturn( when(profileRepository.findQualifications(10001L)).thenReturn(
List.of(approvedQualification("BUSINESS_LICENSE")), List.of(approvedQualification("BUSINESS_LICENSE")),
List.of(approvedQualification("BUSINESS_LICENSE"), approvedQualification("LEGAL_PERSON_ID"), List.of(approvedQualification("BUSINESS_LICENSE"), approvedQualification("LEGAL_PERSON_ID"),
approvedQualification("FREIGHT_FORWARDER_CERTIFICATE"), rejectedQualification("FREIGHT_FORWARDER_CERTIFICATE")));
approvedQualification("BANK_ACCOUNT_LICENSE")));
var withoutQualification = service.orderEligibility(10001L); var withoutQualification = service.orderEligibility(10001L);
var withQualification = service.orderEligibility(10001L); var withQualification = service.orderEligibility(10001L);
@@ -332,6 +329,12 @@ class TenantServiceTest {
return item; return item;
} }
private IamTenantQualification rejectedQualification(String type) {
IamTenantQualification item = qualification(type);
item.setStatus("REJECTED");
return item;
}
private IamTenantReview review(long id, long tenantId) { private IamTenantReview review(long id, long tenantId) {
IamTenantReview review = new IamTenantReview(); IamTenantReview review = new IamTenantReview();
review.setId(id); review.setTenantId(tenantId); review.setStatus("PENDING"); review.setId(id); review.setTenantId(tenantId); review.setStatus("PENDING");
+3 -3
View File
@@ -9,12 +9,12 @@
## 接口隔离 / API separation ## 接口隔离 / API separation
- 商户端 `/api/merchant/v1/orders/first-leg`:草稿、编辑、提交、查询、详情、取消。 / Merchant draft, edit, submit, query, detail, and cancel APIs. - 商户端 `/api/merchant/v1/orders/first-leg`:草稿、编辑、提交、查询、详情、取消。 / Merchant draft, edit, submit, query, detail, and cancel APIs.
- 平台端 `/api/platform/v1/orders/first-leg`:租户订单查询、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform tenant query, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes. - 平台端 `/api/platform/v1/orders/first-leg`:跨租户订单列表、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform cross-tenant list, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes.
- 平台指派 `/api/platform/v1/first-leg/tasks/{taskId}/assignment`。 / Platform fulfillment assignment. - 平台指派 `/api/platform/v1/first-leg/tasks/{taskId}/assignment`。 / Platform fulfillment assignment.
商户接口要求 `appCode=MERCHANT`,租户来自 JWT。平台接口要求 `appCode=PLATFORM`,并显式传入目标 `tenantId`;所有服务层 SQL 都带租户条件。 商户接口要求 `appCode=MERCHANT`,租户来自 JWT。平台列表接口要求 `appCode=PLATFORM`,省略 `tenantId` 时查询全部客户租户,传入时按单个租户筛选;平台详情和写操作仍必须显式传入目标 `tenantId`。
Merchant APIs require `appCode=MERCHANT` and derive the tenant from JWT. Platform APIs require `appCode=PLATFORM` and an explicit target `tenantId`; every service query includes the tenant predicate. Merchant APIs require `appCode=MERCHANT` and derive the tenant from JWT. The platform list requires `appCode=PLATFORM`: omitting `tenantId` queries all customer tenants, while supplying it filters one tenant. Platform detail and write operations still require an explicit target `tenantId`.
## 状态与审计 / State and audit ## 状态与审计 / State and audit
@@ -31,11 +31,11 @@ import org.springframework.web.bind.annotation.PutMapping;
import java.util.List; import java.util.List;
/** /**
* 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。 * 平台端头程订单管理接口,提供跨租户列表查询,并在明确的目标租户范围内执行详情查询和状态流转。
*/ */
@Tag( @Tag(
name = "Platform first-leg orders / 平台头程订单", name = "Platform first-leg orders / 平台头程订单",
description = "Platform lifecycle management for orders in an explicit target tenant. / 平台端针对明确目标租户管理头程订单生命周期。" description = "Platform review queue across tenants; detail and lifecycle actions require an explicit target tenant. / 平台端跨租户审核队列;详情和生命周期操作需要明确目标租户。"
) )
@RestController @RestController
@RequestMapping("/api/platform/v1/orders/first-leg") @RequestMapping("/api/platform/v1/orders/first-leg")
@@ -98,7 +98,7 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。") @Operation(summary = "Platform order query / 平台订单查询", description = "Queries all tenant orders when tenantId is omitted, or filters one tenant when supplied. / tenantId 省略时分页查询全部客户租户订单,传入时仅查询指定租户。")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
@@ -107,9 +107,14 @@ public class PlatformFirstLegOrderController {
@PreAuthorize("hasAuthority('platform:order:list')") @PreAuthorize("hasAuthority('platform:order:list')")
public ApiResponse<PageResult<OrderEntity>> page( public ApiResponse<PageResult<OrderEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId, @Parameter(description = "Optional target tenant filter / 可选的目标租户筛选", example = "10001")
@RequestParam(required = false) Long tenantId,
@Valid OrderRequests.QueryRequest query) { @Valid OrderRequests.QueryRequest query) {
return ApiResponse.success(service.page(OrderScopeGuard.platform(user, tenantId), query)); OrderScopeGuard.platform(user);
if (tenantId != null) {
OrderScopeGuard.platform(user, tenantId);
}
return ApiResponse.success(service.page(tenantId, query));
} }
@GetMapping("/tenant-counts") @GetMapping("/tenant-counts")
@@ -44,10 +44,10 @@ public final class OrderRequests {
LocalDate expectedShipDate, LocalDate expectedShipDate,
@Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000) @Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000)
@Size(max = 1000) String customerRemark, @Size(max = 1000) String customerRemark,
@Schema(description = "Sender information / 发货方信息", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Sender information; required when pickupMethod is PICKUP / 发货方信息,取货方式为 PICKUP 时必填")
@NotNull @Valid Party sender, @Valid Party sender,
@Schema(description = "Receiver information / 收货方信息", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Receiver information; required for PRIVATE destination / 收货方信息,私人地址目的地时必填")
@NotNull @Valid Party receiver, @Valid Party receiver,
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List<Cargo> cargoes, @NotEmpty @Valid List<Cargo> cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod, @NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@@ -73,9 +73,9 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId)); .eq(OrderEntity::getId, orderId));
} }
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>() LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId) .eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.eq(query.status() != null, OrderEntity::getStatus, query.status()) .eq(query.status() != null, OrderEntity::getStatus, query.status())
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId()) .eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
.eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, query.destinationWarehouseId()) .eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, query.destinationWarehouseId())
@@ -106,7 +106,7 @@ public class OrderApplicationService {
completeIdempotency(idempotency, id); completeIdempotency(idempotency, id);
} }
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
public List<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) { public List<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) {
var counts = repository.countByTenantIds(tenantIds); var counts = repository.countByTenantIds(tenantIds);
@@ -145,6 +145,12 @@ public class OrderApplicationService {
} }
private void validatePrototypeFields(OrderRequests.SaveRequest request) { private void validatePrototypeFields(OrderRequests.SaveRequest request) {
if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
throw new BizException("上门提货时必须填写发货信息");
}
if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
throw new BizException("私人地址必须填写收货信息");
}
if ("FBA".equals(request.destinationType())) { if ("FBA".equals(request.destinationType())) {
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId()) if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|| !StringUtils.hasText(request.referenceId())) { || !StringUtils.hasText(request.referenceId())) {
@@ -168,6 +174,13 @@ public class OrderApplicationService {
} }
var cargoes = repository.findCargo(tenantId, order.getId()); var cargoes = repository.findCargo(tenantId, order.getId());
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required"); if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
var parties = repository.findParties(tenantId, order.getId());
if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) {
throw new BizException("上门提货时必须填写发货信息");
}
if ("PRIVATE".equals(order.getDestinationType()) && !hasParty(parties, "RECEIVER")) {
throw new BizException("私人地址必须填写收货信息");
}
if ("FBA".equals(order.getDestinationType())) { if ("FBA".equals(order.getDestinationType())) {
if (!StringUtils.hasText(order.getAmazonWarehouseCode()) || !StringUtils.hasText(order.getShipmentId()) if (!StringUtils.hasText(order.getAmazonWarehouseCode()) || !StringUtils.hasText(order.getShipmentId())
|| !StringUtils.hasText(order.getReferenceId())) { || !StringUtils.hasText(order.getReferenceId())) {
@@ -181,6 +194,11 @@ public class OrderApplicationService {
} }
} }
private boolean hasParty(List<OrderPartyEntity> parties, String partyType) {
return parties != null && parties.stream()
.anyMatch(party -> party != null && partyType.equals(party.getPartyType()));
}
private OrderEntity require(long tenantId, long id) { private OrderEntity require(long tenantId, long id) {
OrderEntity order = repository.findOwned(tenantId, id); OrderEntity order = repository.findOwned(tenantId, id);
if (order == null) throw new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND, "Order not found"); if (order == null) throw new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND, "Order not found");
@@ -272,7 +290,8 @@ public class OrderApplicationService {
}; };
} }
private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) { private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(snapshots.partner().id()); order.setCustomerName(snapshots.partner().partnerName()); order.setCustomerId(snapshots.partner() == null ? null : snapshots.partner().id());
order.setCustomerName(snapshots.partner() == null ? request.customerName() : snapshots.partner().partnerName());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm()); order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode() order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
: snapshots.destination().warehouse().countryCode()); : snapshots.destination().warehouse().countryCode());
@@ -324,8 +343,8 @@ public class OrderApplicationService {
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand()); item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
} }
saveParty(tenantId, userId, orderId, "SENDER", request.sender()); if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver()); if (request.receiver() != null) saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver());
} }
private void saveParty(long tenantId, long userId, long orderId, String type, OrderRequests.Party party) { private void saveParty(long tenantId, long userId, long orderId, String type, OrderRequests.Party party) {
OrderPartyEntity item = new OrderPartyEntity(); OrderPartyEntity item = new OrderPartyEntity();
@@ -28,9 +28,8 @@ public class OrderMasterDataGuard {
this.warehouses=warehouses; this.partners=partners; this.token=token; this.warehouses=warehouses; this.partners=partners; this.token=token;
} }
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) { public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
if (request.customerId() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "必须选择往来单位");
try { try {
var partner=loadPartner(tenantId,request.customerId()); PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(tenantId,request.customerId());
var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库"); var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库");
requireWarehouseType(receiving, "DOMESTIC", "收货仓库"); requireWarehouseType(receiving, "DOMESTIC", "收货仓库");
if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull)) if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull))
@@ -62,8 +61,7 @@ public class OrderMasterDataGuard {
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId,
Long destinationWarehouseId, String destinationType) { Long destinationWarehouseId, String destinationType) {
try { try {
if (partnerId==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位已停用或不可用"); if (partnerId!=null) loadPartner(tenantId,partnerId);
loadPartner(tenantId,partnerId);
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用"); if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库"); WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库");
requireWarehouseType(receiving,"DOMESTIC","收货仓库"); requireWarehouseType(receiving,"DOMESTIC","收货仓库");
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exce
*/ */
public final class OrderScopeGuard { private OrderScopeGuard(){} public final class OrderScopeGuard { private OrderScopeGuard(){}
public static long merchant(AuthenticatedUser u){requireApp(u,"MERCHANT");if(u.tenantId()==null||u.tenantId()<=0)throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant is required");return u.tenantId();} public static long merchant(AuthenticatedUser u){requireApp(u,"MERCHANT");if(u.tenantId()==null||u.tenantId()<=0)throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant is required");return u.tenantId();}
public static void platform(AuthenticatedUser u){requireApp(u,"PLATFORM");}
public static long platform(AuthenticatedUser u,long targetTenantId){requireApp(u,"PLATFORM");if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;} public static long platform(AuthenticatedUser u,long targetTenantId){requireApp(u,"PLATFORM");if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;}
private static void requireApp(AuthenticatedUser u,String expected){if(u==null||!expected.equals(u.appCode()))throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope");} private static void requireApp(AuthenticatedUser u,String expected){if(u==null||!expected.equals(u.appCode()))throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope");}
} }
@@ -27,6 +27,18 @@ paths:
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断} '200': {description: 成功或业务失败,按响应体 code 判断}
/api/platform/v1/orders/first-leg: /api/platform/v1/orders/first-leg:
get:
summary: 平台分页查询头程订单
description: tenantId 省略时查询全部客户租户订单,传入时仅查询指定租户。
parameters:
- name: tenantId
in: query
required: false
description: 可选的目标租户筛选
schema: {type: integer, format: int64, minimum: 1}
responses:
'200': {description: 查询成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:list 权限和平台身份}
post: post:
summary: 平台代客创建订单草稿 summary: 平台代客创建订单草稿
parameters: parameters:
@@ -24,7 +24,7 @@ class OrderControllerPermissionTest {
@Test @Test
void platformOrderListAndDetailUseConfiguredListPermission() throws Exception { void platformOrderListAndDetailUseConfiguredListPermission() throws Exception {
assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')", assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class); AuthenticatedUser.class, Long.class, OrderRequests.QueryRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')", assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, long.class); AuthenticatedUser.class, long.class, long.class);
assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')", assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')",
@@ -0,0 +1,72 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.service.OrderApplicationService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
class PlatformOrderPageControllerTest {
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@Test
void omittedTenantQueriesAcrossAllTenantsForPlatformUser() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
controller.page(user("PLATFORM"), null, query);
verify(service).page(null, query);
}
@Test
void suppliedTenantFiltersOneTenantForPlatformUser() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
controller.page(user("PLATFORM"), 2084455952401629185L, query);
verify(service).page(2084455952401629185L, query);
}
@Test
void invalidTenantFilterIsRejectedBeforeQuery() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
assertThatThrownBy(() -> controller.page(user("PLATFORM"), 0L, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Target tenant is required");
verifyNoInteractions(service);
}
@Test
void merchantCannotUseCrossTenantPlatformList() {
OrderApplicationService service = mock(OrderApplicationService.class);
PlatformFirstLegOrderController controller = controller(service);
assertThatThrownBy(() -> controller.page(user("MERCHANT"), null, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Wrong application scope");
verifyNoInteractions(service);
}
private PlatformFirstLegOrderController controller(OrderApplicationService service) {
PlatformFirstLegOrderController controller = new PlatformFirstLegOrderController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
private AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(1L, null, null, null, "operator", appCode, "WEB", Set.of());
}
}
@@ -0,0 +1,56 @@
package com.baiyue.order.repository;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.mapper.OrderCargoMapper;
import com.baiyue.order.mapper.OrderChildPurgeMapper;
import com.baiyue.order.mapper.OrderIdempotencyRecordMapper;
import com.baiyue.order.mapper.OrderInternalNoteMapper;
import com.baiyue.order.mapper.OrderMapper;
import com.baiyue.order.mapper.OrderPartyMapper;
import com.baiyue.order.mapper.OrderStatusHistoryMapper;
import com.baiyue.order.mapper.OutboxEventMapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PlatformOrderPageRepositoryTest {
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void tenantPredicateIsAddedOnlyWhenFilterIsSupplied() {
OrderMapper mapper = mock(OrderMapper.class);
when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
OrderRepository repository = repository(mapper);
repository.page(null, query);
repository.page(20002L, query);
ArgumentCaptor<Wrapper<OrderEntity>> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper, org.mockito.Mockito.times(2)).selectPage(any(Page.class), wrapperCaptor.capture());
assertThat(wrapperCaptor.getAllValues().get(0).getExpression().getNormal()).isEmpty();
assertThat(wrapperCaptor.getAllValues().get(1).getExpression().getNormal()).isNotEmpty();
}
private OrderRepository repository(OrderMapper mapper) {
return new OrderRepository(
mapper,
mock(OrderCargoMapper.class),
mock(OrderPartyMapper.class),
mock(OrderInternalNoteMapper.class),
mock(OrderStatusHistoryMapper.class),
mock(OutboxEventMapper.class),
mock(OrderChildPurgeMapper.class),
mock(OrderIdempotencyRecordMapper.class)
);
}
}
@@ -6,6 +6,7 @@ import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -98,6 +99,68 @@ class OrderApplicationServiceStateTest {
verify(repository,never()).saveOrder(any()); verify(repository,never()).saveOrder(any());
} }
@Test
void publicDestinationCanBeSavedWithoutPartnerSenderOrReceiver() {
OrderRepository repository = mock(OrderRepository.class);
OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(null, "SELF_EXPRESS", null, null, "OVERSEAS");
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
when(repository.completeIdempotency(any(), anyLong())).thenReturn(1);
service(repository, eligibility, masterData)
.createDraft(20002L, 2L, request, "public-destination-no-parties");
verify(repository).insertOrder(argThat(order -> order.getCustomerId() == null
&& "当前企业".equals(order.getCustomerName())));
verify(repository, never()).insertParty(any());
}
@Test
void pickupStillRequiresSender() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(null, "PICKUP", null, null, "OVERSEAS");
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(20002L, 2L, request, "pickup-without-sender"))
.isInstanceOf(BizException.class)
.hasMessageContaining("上门提货时必须填写发货信息");
verifyNoInteractions(masterData);
}
@Test
void privateDestinationStillRequiresReceiver() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(null, "SELF_EXPRESS", null, null, "PRIVATE");
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(20002L, 2L, request, "private-without-receiver"))
.isInstanceOf(BizException.class)
.hasMessageContaining("私人地址必须填写收货信息");
verifyNoInteractions(masterData);
}
@Test
void submittingPersistedPickupDraftRequiresSenderSnapshot() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("PICKUP", "OVERSEAS");
OrderCargoEntity cargo = new OrderCargoEntity();
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("上门提货时必须填写发货信息");
verify(repository, never()).saveOrder(any());
}
private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility,
OrderMasterDataGuard masterData) { OrderMasterDataGuard masterData) {
OrderApplicationService service = new OrderApplicationService(); OrderApplicationService service = new OrderApplicationService();
@@ -121,12 +184,44 @@ class OrderApplicationServiceStateTest {
"BUY_DECLARATION", "LCL", false, "OVERSEAS", null, null, null); "BUY_DECLARATION", "LCL", false, "OVERSEAS", null, null, null);
} }
private OrderRequests.SaveRequest request(Long customerId, String pickupMethod,
OrderRequests.Party sender, OrderRequests.Party receiver,
String destinationType) {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
null, null, null, null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道",
"BUY_DECLARATION", "LCL", false, destinationType, null, null, null);
}
private OrderMasterDataGuard.Snapshots snapshots() { private OrderMasterDataGuard.Snapshots snapshots() {
var partner = new PartnerClient.Partner(7L, 10001L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER")); return snapshots(new PartnerClient.Partner(7L, 10001L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER")));
}
private OrderMasterDataGuard.Snapshots snapshots(PartnerClient.Partner partner) {
var warehouse = new WarehouseClient.Warehouse(8L, 10001L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE"); var warehouse = new WarehouseClient.Warehouse(8L, 10001L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE");
var address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null); var address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null);
var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0)); var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0));
var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability)); var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability));
return new OrderMasterDataGuard.Snapshots(partner, receiving, null); return new OrderMasterDataGuard.Snapshots(partner, receiving, null);
} }
private OrderEntity persistedDraft(String pickupMethod, String destinationType) {
OrderEntity order = new OrderEntity();
order.setId(1L);
order.setTenantId(20002L);
order.setStatus(OrderStatus.DRAFT);
order.setPickupMethod(pickupMethod);
order.setReceivingWarehouseId(8L);
order.setReceivingWarehouseName("收货仓");
order.setChannelCode("CH");
order.setChannelName("渠道");
order.setTaxMode("BUY_DECLARATION");
order.setCustomsInspection(false);
order.setDestinationType(destinationType);
return order;
}
} }
@@ -168,6 +168,19 @@ class OrderMasterDataGuardTest {
verify(warehouses).active("token",PUBLIC_TENANT_ID,8); verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
} }
@Test @Test
void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
var result=new OrderMasterDataGuard(warehouses,partners,"token")
.validate(20002,request(null,8L));
assertThat(result.partner()).isNull();
verifyNoInteractions(partners);
verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void fbaDestinationMustReferenceMatchingPublicWarehouse() { void fbaDestinationMustReferenceMatchingPublicWarehouse() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( when(partners.active("token",20002,7)).thenReturn(ApiResponse.success(
@@ -0,0 +1,27 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class PlatformOrderPageServiceTest {
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@Test
void passesOptionalTenantScopeToRepository() {
OrderRepository repository = mock(OrderRepository.class);
OrderApplicationService service = new OrderApplicationService();
ReflectionTestUtils.setField(service, "repository", repository);
service.page(null, query);
service.page(20002L, query);
verify(repository).page(null, query);
verify(repository).page(20002L, query);
}
}