Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01
This commit is contained in:
@@ -47,7 +47,7 @@ public class AuthController {
|
||||
@SecurityRequirements
|
||||
@PostMapping("/register")
|
||||
@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) {
|
||||
return ApiResponse.success(registrationService.register(request));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 模块使用、RocketMQ 部署与导出排查 | `README.md`;完整架构与接口契约见 `../docs/公共导出与下载中心接入说明.md` |
|
||||
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` |
|
||||
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
|
||||
| 服务状态 | `controller/FileHealthController.java` |
|
||||
|
||||
+125
@@ -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
@@ -16,7 +16,7 @@
|
||||
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
|
||||
| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
|
||||
| 平台企业客户档案 | `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` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 |
|
||||
| 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` |
|
||||
@@ -26,16 +26,16 @@
|
||||
| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 |
|
||||
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
|
||||
| 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。
|
||||
- 统一用户创建要求 `platform:user:create` 与 `platform:user:grant-role`;跨租户或修改角色还需授角权限。角色必须属于目标租户和对应 `PLATFORM/MERCHANT` appCode,受保护的平台管理员不得被降权、停用或自停用。
|
||||
- 跨租户移动用户时必须同步成员租户、角色和 Auth 账号 scope;协作入口为 `client/AuthAccountClient.java` 与 Auth 的 `PUT /internal/auth/accounts/{id}/scope`,任一步失败都应回滚本地事务或执行补偿。
|
||||
- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业租户成员必须走商户成员边界。
|
||||
- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台租户且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。
|
||||
- `/users` 不提供跨租户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。
|
||||
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
|
||||
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
|
||||
- 平台资质登记在事务内通过 `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`。
|
||||
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
|
||||
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `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` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
|
||||
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
|
||||
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
|
||||
|
||||
+3
-3
@@ -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 / 待审核列表 |
|
||||
| POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve 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 | `/api/platform/v1/iam/users/options` | PLATFORM | Tenant and role options / 用户租户与角色选项 |
|
||||
| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit profile, tenant and roles / 编辑用户资料、租户和角色 |
|
||||
| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Paginated platform-internal user administration (`tenant_id=0`) / 平台内部用户分页管理 |
|
||||
| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform tenant and PLATFORM role options / 平台租户与平台角色选项 |
|
||||
| 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}/password` | PLATFORM | Reset password / 重置密码 |
|
||||
| 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")
|
||||
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
|
||||
@Operation(summary = "提交当前企业资质审核", description = "校验四类必填资质齐全后创建审核单。上传或替换文件不会自动送审。")
|
||||
@Operation(summary = "提交当前企业资质审核", description = "校验营业执照和法人身份证齐全后创建审核单;货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。")
|
||||
public ApiResponse<TenantResponse> submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
|
||||
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.tags.Tag;
|
||||
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.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 原型“用户管理”的平台统一入口,覆盖平台与企业租户账号。 */
|
||||
/** Admin 内部用户管理入口,只允许维护平台租户账号。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/iam/users")
|
||||
@Validated
|
||||
@Tag(name = "Platform user administration / 平台用户管理")
|
||||
public class PlatformUserManagementController {
|
||||
private final PlatformUserManagementService service;
|
||||
@@ -27,15 +31,20 @@ public class PlatformUserManagementController {
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:user:list')")
|
||||
@Operation(summary = "统一查询平台及企业租户用户")
|
||||
public ApiResponse<List<UserResponse>> list(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
@Operation(summary = "分页查询平台内部用户")
|
||||
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");
|
||||
return ApiResponse.success(service.list());
|
||||
return ApiResponse.success(service.page(page, size, keyword, status));
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
@PreAuthorize("hasAuthority('platform:user:list')")
|
||||
@Operation(summary = "查询用户管理可选租户和角色")
|
||||
@Operation(summary = "查询平台内部用户可选平台角色")
|
||||
public ApiResponse<UserOptions> options(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.options());
|
||||
@@ -43,7 +52,7 @@ public class PlatformUserManagementController {
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')")
|
||||
@Operation(summary = "新增平台或企业租户用户")
|
||||
@Operation(summary = "新增平台内部用户")
|
||||
public ApiResponse<UserResponse> create(@Valid @RequestBody CreateUserRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
@@ -52,7 +61,7 @@ public class PlatformUserManagementController {
|
||||
|
||||
@PutMapping("/{membershipId:\\d+}")
|
||||
@PreAuthorize("hasAuthority('platform:user:edit')")
|
||||
@Operation(summary = "编辑用户资料、租户、角色、状态和密码")
|
||||
@Operation(summary = "编辑平台内部用户资料和角色")
|
||||
public ApiResponse<UserResponse> update(@PathVariable Long membershipId,
|
||||
@Valid @RequestBody UpdateUserRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 平台统一用户管理契约,覆盖平台账号与企业租户账号。 */
|
||||
/** Admin 平台内部用户管理契约;tenantId 保留用于兼容既有请求,但只能为 0。 */
|
||||
public final class PlatformUserManagementDtos {
|
||||
private PlatformUserManagementDtos() { }
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamMembershipRole;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
@@ -8,7 +9,9 @@ import com.baiyue.iam.mapper.IamMembershipRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamUserMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -30,9 +33,27 @@ public class MemberRepository {
|
||||
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt));
|
||||
}
|
||||
public List<IamMembership> findAll() {
|
||||
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.orderByDesc(IamMembership::getJoinedAt));
|
||||
public PageResult<IamMembership> pageByTenant(long tenantId, long page, long size,
|
||||
String keyword, String status) {
|
||||
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 findByTenantAndId(long tenantId, Long id) {
|
||||
|
||||
@@ -2,17 +2,16 @@ package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
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.exception.BizException;
|
||||
import com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
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.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
@@ -31,45 +30,41 @@ import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 平台管理员跨租户维护登录用户;凭据仍只由 auth-service 保存。 */
|
||||
/** 平台管理员维护内部登录用户;凭据仍只由 auth-service 保存。 */
|
||||
@Service
|
||||
public class PlatformUserManagementService {
|
||||
private static final long PLATFORM_TENANT_ID = 0L;
|
||||
private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class);
|
||||
|
||||
private final MemberRepository members;
|
||||
private final TenantRepository tenants;
|
||||
private final IamRoleMapper roleMapper;
|
||||
private final AuthAccountClient authClient;
|
||||
private final String internalToken;
|
||||
|
||||
public PlatformUserManagementService(MemberRepository members, TenantRepository tenants,
|
||||
IamRoleMapper roleMapper, AuthAccountClient authClient,
|
||||
public PlatformUserManagementService(MemberRepository members, IamRoleMapper roleMapper,
|
||||
AuthAccountClient authClient,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.members = members;
|
||||
this.tenants = tenants;
|
||||
this.roleMapper = roleMapper;
|
||||
this.authClient = authClient;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserResponse> list() {
|
||||
Map<Long, IamTenant> tenantMap = tenants.listMerchants().stream()
|
||||
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
|
||||
Map<Long, IamRole> roleMap = allRoles().stream()
|
||||
public PageResult<UserResponse> page(long page, long size, String keyword, String status) {
|
||||
PageResult<IamMembership> source = members.pageByTenant(
|
||||
PLATFORM_TENANT_ID, page, size, keyword, status);
|
||||
Map<Long, IamRole> roleMap = platformRoles().stream()
|
||||
.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)
|
||||
public UserOptions options() {
|
||||
List<TenantOption> tenantOptions = new java.util.ArrayList<>();
|
||||
tenantOptions.add(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE"));
|
||||
tenants.listMerchants().stream()
|
||||
.map(value -> new TenantOption(value.getId(), value.getTenantName(), value.getStatus()))
|
||||
.forEach(tenantOptions::add);
|
||||
List<RoleOption> roleOptions = allRoles().stream()
|
||||
List<TenantOption> tenantOptions = List.of(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE"));
|
||||
List<RoleOption> roleOptions = platformRoles().stream()
|
||||
.map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus()))
|
||||
.toList();
|
||||
return new UserOptions(List.copyOf(tenantOptions), roleOptions);
|
||||
@@ -80,7 +75,7 @@ public class PlatformUserManagementService {
|
||||
resourceId = "#request.username", description = "平台新增用户", captureAfter = false)
|
||||
public UserResponse create(CreateUserRequest request) {
|
||||
long tenantId = request.tenantId();
|
||||
validateTenant(tenantId);
|
||||
requirePlatformTenant(tenantId);
|
||||
List<Long> roleIds = validateRoles(tenantId, request.roleIds());
|
||||
long authUserId = IdWorker.getId();
|
||||
long userId = IdWorker.getId();
|
||||
@@ -122,11 +117,11 @@ public class PlatformUserManagementService {
|
||||
@BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false)
|
||||
public UserResponse update(Long membershipId, UpdateUserRequest request, Set<String> operatorPermissions) {
|
||||
IamMembership membership = required(membershipId);
|
||||
IamMembership membership = requiredPlatformMembership(membershipId);
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
long oldTenantId = membership.getTenantId();
|
||||
long newTenantId = request.tenantId();
|
||||
validateTenant(newTenantId);
|
||||
requirePlatformTenant(newTenantId);
|
||||
List<Long> roleIds = validateRoles(newTenantId, request.roleIds());
|
||||
List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId);
|
||||
if (isProtectedPlatformAdmin(membership, oldRoleIds)
|
||||
@@ -170,7 +165,7 @@ public class PlatformUserManagementService {
|
||||
@BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "平台启停用户", captureAfter = false)
|
||||
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())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户");
|
||||
}
|
||||
@@ -200,39 +195,35 @@ public class PlatformUserManagementService {
|
||||
@BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false)
|
||||
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(),
|
||||
new AuthAccountClient.PasswordRequest(request.newPassword())));
|
||||
}
|
||||
|
||||
private UserResponse response(IamMembership membership) {
|
||||
Map<Long, IamTenant> tenantMap = tenants.listMerchants().stream()
|
||||
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
|
||||
Map<Long, IamRole> roleMap = allRoles().stream()
|
||||
Map<Long, IamRole> roleMap = platformRoles().stream()
|
||||
.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,
|
||||
Map<Long, IamRole> roleMap) {
|
||||
private UserResponse response(IamMembership membership, Map<Long, IamRole> roleMap) {
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
AuthAccountClient.AccountSummary account = account(user.getAuthUserId());
|
||||
List<Long> roleIds = members.findRoleIds(membership.getTenantId(), membership.getId());
|
||||
List<String> roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull)
|
||||
.map(IamRole::getRoleName).toList();
|
||||
String tenantName = membership.getTenantId() == PLATFORM_TENANT_ID ? "平台"
|
||||
: tenantMap.containsKey(membership.getTenantId())
|
||||
? tenantMap.get(membership.getTenantId()).getTenantName() : "未知租户";
|
||||
String tenantName = "平台";
|
||||
return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(),
|
||||
user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName,
|
||||
membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds,
|
||||
roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds));
|
||||
}
|
||||
|
||||
private List<IamRole> allRoles() {
|
||||
private List<IamRole> platformRoles() {
|
||||
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
|
||||
.in(IamRole::getAppCode, "PLATFORM", "MERCHANT")
|
||||
.orderByAsc(IamRole::getTenantId).orderByAsc(IamRole::getRoleName));
|
||||
.eq(IamRole::getTenantId, PLATFORM_TENANT_ID)
|
||||
.eq(IamRole::getAppCode, "PLATFORM")
|
||||
.orderByAsc(IamRole::getRoleName));
|
||||
}
|
||||
|
||||
private List<Long> validateRoles(long tenantId, List<Long> requested) {
|
||||
@@ -257,11 +248,9 @@ public class PlatformUserManagementService {
|
||||
.anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode()));
|
||||
}
|
||||
|
||||
private void validateTenant(long tenantId) {
|
||||
if (tenantId == PLATFORM_TENANT_ID) return;
|
||||
IamTenant tenant = tenants.findTenant(tenantId);
|
||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType()) || !"ACTIVE".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "只能选择正常状态的企业租户");
|
||||
private void requirePlatformTenant(long tenantId) {
|
||||
if (tenantId != PLATFORM_TENANT_ID) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部用户只能属于平台租户");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +260,14 @@ public class PlatformUserManagementService {
|
||||
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) {
|
||||
IamUser user = members.findUser(userId);
|
||||
if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在");
|
||||
@@ -296,7 +293,7 @@ public class PlatformUserManagementService {
|
||||
try {
|
||||
call(() -> authClient.compensate(internalToken, authUserId));
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("统一用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
log.error("内部用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
@@ -311,7 +308,7 @@ public class PlatformUserManagementService {
|
||||
if (profileChanged) call(() -> authClient.profile(internalToken, authUserId,
|
||||
new AuthAccountClient.ProfileRequest(oldAccount.displayName())));
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("统一用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
log.error("内部用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.util.Set;
|
||||
@Service
|
||||
public class TenantService {
|
||||
private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of(
|
||||
"BUSINESS_LICENSE", "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE");
|
||||
"BUSINESS_LICENSE", "LEGAL_PERSON_ID");
|
||||
@Resource
|
||||
private TenantRepository repository;
|
||||
@Resource
|
||||
@@ -273,7 +273,7 @@ public class TenantService {
|
||||
var qualifications = profileRepository.findQualifications(tenantId);
|
||||
if (!hasAllPendingQualifications(qualifications)) {
|
||||
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();
|
||||
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
|
||||
|
||||
@@ -210,13 +210,22 @@ paths:
|
||||
/api/platform/v1/iam/users:
|
||||
get:
|
||||
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:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 仅返回 tenant_id=0 的平台内部用户分页
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PlatformManagedUserPageApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 新增平台或企业租户用户
|
||||
summary: 新增平台内部用户
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -228,7 +237,7 @@ paths:
|
||||
/api/platform/v1/iam/users/options:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询用户管理可选租户和角色
|
||||
summary: 查询平台租户和 PLATFORM 角色选项
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
@@ -237,7 +246,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/MembershipId'}
|
||||
put:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 编辑用户资料、所属租户及角色
|
||||
summary: 编辑平台内部用户资料及角色
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -251,7 +260,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/MembershipId'}
|
||||
put:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 启用或停用平台及企业租户用户
|
||||
summary: 启用或停用平台内部用户
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -265,7 +274,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/MembershipId'}
|
||||
put:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 重置平台及企业租户用户密码
|
||||
summary: 重置平台内部用户密码
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -472,7 +481,7 @@ paths:
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: Submit enterprise qualifications for review / 提交企业资质审核
|
||||
description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review only after all four required qualification types have been provided. / 上传或替换资质不会自动送审;四类必填资质齐全后由此接口创建审核单。
|
||||
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:
|
||||
'200': {$ref: '#/components/responses/TenantSuccess'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
@@ -748,6 +757,20 @@ components:
|
||||
- type: object
|
||||
properties:
|
||||
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:
|
||||
type: object
|
||||
required: [id, tenantCode, tenantName, status, servicePackage]
|
||||
@@ -883,7 +906,7 @@ components:
|
||||
displayName: {type: string, maxLength: 64}
|
||||
phone: {type: string, pattern: '^1\\d{10}$'}
|
||||
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}}
|
||||
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
@@ -894,7 +917,7 @@ components:
|
||||
displayName: {type: string, maxLength: 64}
|
||||
phone: {type: string, pattern: '^1\\d{10}$'}
|
||||
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}}
|
||||
CreateInternalUserRequest:
|
||||
type: object
|
||||
@@ -948,6 +971,26 @@ components:
|
||||
memberNo: {type: string}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
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:
|
||||
type: object
|
||||
required: [username, password, displayName, memberType]
|
||||
|
||||
@@ -37,6 +37,7 @@ class PlatformInternalUserContractTest {
|
||||
@Test
|
||||
void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception {
|
||||
Method list = PlatformUserManagementController.class.getMethod("list",
|
||||
long.class, long.class, String.class, String.class,
|
||||
com.baiyue.common.security.AuthenticatedUser.class);
|
||||
Method create = PlatformUserManagementController.class.getMethod("create",
|
||||
com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class,
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -25,110 +23,89 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class PlatformUserManagementServiceTest {
|
||||
private final MemberRepository members = mock(MemberRepository.class);
|
||||
private final TenantRepository tenants = mock(TenantRepository.class);
|
||||
private final IamRoleMapper roles = mock(IamRoleMapper.class);
|
||||
private final AuthAccountClient auth = mock(AuthAccountClient.class);
|
||||
private final PlatformUserManagementService service =
|
||||
new PlatformUserManagementService(members, tenants, roles, auth, "internal-token");
|
||||
private IamTenant merchant;
|
||||
private IamRole merchantRole;
|
||||
new PlatformUserManagementService(members, roles, auth, "internal-token");
|
||||
private IamRole platformRole;
|
||||
private IamMembership membership;
|
||||
private IamUser user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
merchant = new IamTenant();
|
||||
merchant.setId(10001L); merchant.setTenantType("MERCHANT"); merchant.setTenantName("华远贸易");
|
||||
merchant.setStatus("ACTIVE");
|
||||
merchantRole = new IamRole();
|
||||
merchantRole.setId(40001L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT");
|
||||
merchantRole.setRoleName("租户管理员"); merchantRole.setStatus("ACTIVE");
|
||||
platformRole = new IamRole();
|
||||
platformRole.setId(40001L); platformRole.setTenantId(0L); platformRole.setAppCode("PLATFORM");
|
||||
platformRole.setRoleName("平台运营"); platformRole.setStatus("ACTIVE");
|
||||
membership = new IamMembership();
|
||||
membership.setId(30L); membership.setTenantId(10001L); membership.setUserId(20L);
|
||||
membership.setMemberType("EMPLOYEE"); membership.setMemberNo("MEMBER-30"); membership.setStatus("ACTIVE");
|
||||
membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L);
|
||||
membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30"); membership.setStatus("ACTIVE");
|
||||
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");
|
||||
|
||||
when(tenants.findTenant(10001L)).thenReturn(merchant);
|
||||
when(tenants.listMerchants()).thenReturn(List.of(merchant));
|
||||
when(roles.selectById(40001L)).thenReturn(merchantRole);
|
||||
when(roles.selectList(any())).thenReturn(List.of(merchantRole));
|
||||
when(roles.selectById(40001L)).thenReturn(platformRole);
|
||||
when(roles.selectList(any())).thenReturn(List.of(platformRole));
|
||||
when(members.findById(30L)).thenReturn(membership);
|
||||
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(
|
||||
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
|
||||
void listReturnsTenantRoleAndLastLoginForMerchantUser() {
|
||||
when(members.findAll()).thenReturn(List.of(membership));
|
||||
void pageReturnsOnlyRepositoryScopedPlatformUsersWithMetadata() {
|
||||
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("华远贸易", response.tenantName());
|
||||
assertEquals(List.of("租户管理员"), response.roleNames());
|
||||
assertEquals("平台", response.tenantName());
|
||||
assertEquals(List.of("平台运营"), response.roleNames());
|
||||
assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt());
|
||||
assertEquals(21, result.total());
|
||||
verify(members).pageByTenant(0L, 2, 10, "张", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createMerchantUserCreatesDisabledMerchantAccountAndMembership() {
|
||||
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);
|
||||
void createRejectsMerchantTenantBeforeCreatingAuthAccount() {
|
||||
CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000",
|
||||
"new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED");
|
||||
|
||||
service.create(request);
|
||||
|
||||
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())));
|
||||
assertThrows(BizException.class, () -> service.create(request));
|
||||
verify(auth, never()).create(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenantMoveUpdatesAuthScopeAndRolesWhenOperatorCanGrantRoles() {
|
||||
IamTenant target = new IamTenant();
|
||||
target.setId(10002L); target.setTenantType("MERCHANT"); target.setTenantName("深圳速达"); target.setStatus("ACTIVE");
|
||||
IamRole targetRole = new IamRole();
|
||||
targetRole.setId(40002L); targetRole.setTenantId(10002L); targetRole.setAppCode("MERCHANT");
|
||||
targetRole.setRoleName("操作员"); targetRole.setStatus("ACTIVE");
|
||||
when(tenants.findTenant(10002L)).thenReturn(target);
|
||||
when(tenants.listMerchants()).thenReturn(List.of(merchant, target));
|
||||
when(roles.selectById(40002L)).thenReturn(targetRole);
|
||||
when(roles.selectList(any())).thenReturn(List.of(merchantRole, targetRole));
|
||||
when(auth.scope(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null));
|
||||
when(auth.profile(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null));
|
||||
void optionsContainOnlyPlatformTenantAndPlatformRoles() {
|
||||
UserOptions result = service.options();
|
||||
|
||||
assertEquals(List.of(new TenantOption(0L, "平台", "ACTIVE")), result.tenants());
|
||||
assertEquals(1, result.roles().size());
|
||||
assertEquals(0L, result.roles().get(0).tenantId());
|
||||
verifyNoInteractions(members);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRejectsMovingPlatformUserToMerchantTenant() {
|
||||
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"));
|
||||
|
||||
verify(auth).scope(eq("internal-token"), eq(10L),
|
||||
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());
|
||||
assertThrows(BizException.class,
|
||||
() -> service.update(30L, request, Set.of("platform:user:grant-role")));
|
||||
verify(auth, never()).scope(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenantOrRoleChangeRequiresGrantRolePermission() {
|
||||
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");
|
||||
when(roles.selectById(40003L)).thenReturn(otherRole);
|
||||
UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null,
|
||||
10001L, List.of(40003L));
|
||||
0L, List.of(40003L));
|
||||
|
||||
assertThrows(BizException.class, () -> service.update(30L, request, Set.of()));
|
||||
verify(auth, never()).scope(anyString(), anyLong(), any());
|
||||
@@ -143,7 +120,6 @@ class PlatformUserManagementServiceTest {
|
||||
|
||||
@Test
|
||||
void builtInPlatformAdministratorCannotBeDisabled() {
|
||||
membership.setTenantId(0L);
|
||||
IamRole platformAdmin = new IamRole();
|
||||
platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM");
|
||||
platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员");
|
||||
@@ -155,4 +131,16 @@ class PlatformUserManagementServiceTest {
|
||||
() -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L));
|
||||
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
|
||||
void explicitSubmissionRequiresAllPrototypeQualificationsAndCreatesReview() {
|
||||
void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
@@ -143,8 +143,7 @@ class TenantServiceTest {
|
||||
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
|
||||
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
|
||||
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
|
||||
|
||||
var submitted = service.submitMerchantReview(10001L);
|
||||
|
||||
@@ -162,7 +161,7 @@ class TenantServiceTest {
|
||||
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
||||
when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED"));
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
|
||||
qualification("BUSINESS_LICENSE")));
|
||||
|
||||
BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
|
||||
|
||||
@@ -205,8 +204,7 @@ class TenantServiceTest {
|
||||
when(repository.findReviewForUpdate(71L)).thenReturn(review);
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
|
||||
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
|
||||
when(provisioning.initialize(any())).thenReturn(
|
||||
TenantAdministratorProvisioningService.ProvisioningResult.created(
|
||||
81L, "merchant_10001", "Password1234"));
|
||||
@@ -285,7 +283,7 @@ class TenantServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业激活不等于具备下单资格仍需四类资质全部审核通过() {
|
||||
void 企业激活后仅需两类必填资质审核通过即可下单() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
@@ -295,8 +293,7 @@ class TenantServiceTest {
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(
|
||||
List.of(approvedQualification("BUSINESS_LICENSE")),
|
||||
List.of(approvedQualification("BUSINESS_LICENSE"), approvedQualification("LEGAL_PERSON_ID"),
|
||||
approvedQualification("FREIGHT_FORWARDER_CERTIFICATE"),
|
||||
approvedQualification("BANK_ACCOUNT_LICENSE")));
|
||||
rejectedQualification("FREIGHT_FORWARDER_CERTIFICATE")));
|
||||
|
||||
var withoutQualification = service.orderEligibility(10001L);
|
||||
var withQualification = service.orderEligibility(10001L);
|
||||
@@ -332,6 +329,12 @@ class TenantServiceTest {
|
||||
return item;
|
||||
}
|
||||
|
||||
private IamTenantQualification rejectedQualification(String type) {
|
||||
IamTenantQualification item = qualification(type);
|
||||
item.setStatus("REJECTED");
|
||||
return item;
|
||||
}
|
||||
|
||||
private IamTenantReview review(long id, long tenantId) {
|
||||
IamTenantReview review = new IamTenantReview();
|
||||
review.setId(id); review.setTenantId(tenantId); review.setStatus("PENDING");
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
## 接口隔离 / API separation
|
||||
|
||||
- 商户端 `/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.
|
||||
|
||||
商户接口要求 `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
|
||||
|
||||
|
||||
+10
-5
@@ -31,11 +31,11 @@ import org.springframework.web.bind.annotation.PutMapping;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。
|
||||
* 平台端头程订单管理接口,提供跨租户列表查询,并在明确的目标租户范围内执行详情查询和状态流转。
|
||||
*/
|
||||
@Tag(
|
||||
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
|
||||
@RequestMapping("/api/platform/v1/orders/first-leg")
|
||||
@@ -98,7 +98,7 @@ public class PlatformFirstLegOrderController {
|
||||
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({
|
||||
@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 / 需要平台端权限")
|
||||
@@ -107,9 +107,14 @@ public class PlatformFirstLegOrderController {
|
||||
@PreAuthorize("hasAuthority('platform:order:list')")
|
||||
public ApiResponse<PageResult<OrderEntity>> page(
|
||||
@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) {
|
||||
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")
|
||||
|
||||
@@ -44,10 +44,10 @@ public final class OrderRequests {
|
||||
LocalDate expectedShipDate,
|
||||
@Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000)
|
||||
@Size(max = 1000) String customerRemark,
|
||||
@Schema(description = "Sender information / 发货方信息", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @Valid Party sender,
|
||||
@Schema(description = "Receiver information / 收货方信息", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull @Valid Party receiver,
|
||||
@Schema(description = "Sender information; required when pickupMethod is PICKUP / 发货方信息,取货方式为 PICKUP 时必填")
|
||||
@Valid Party sender,
|
||||
@Schema(description = "Receiver information; required for PRIVATE destination / 收货方信息,私人地址目的地时必填")
|
||||
@Valid Party receiver,
|
||||
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty @Valid List<Cargo> cargoes,
|
||||
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
|
||||
|
||||
@@ -73,9 +73,9 @@ public class OrderRepository {
|
||||
.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>()
|
||||
.eq(OrderEntity::getTenantId, tenantId)
|
||||
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
|
||||
.eq(query.status() != null, OrderEntity::getStatus, query.status())
|
||||
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
|
||||
.eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, query.destinationWarehouseId())
|
||||
|
||||
@@ -106,7 +106,7 @@ public class OrderApplicationService {
|
||||
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) {
|
||||
var counts = repository.countByTenantIds(tenantIds);
|
||||
@@ -145,6 +145,12 @@ public class OrderApplicationService {
|
||||
}
|
||||
|
||||
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 (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|
||||
|| !StringUtils.hasText(request.referenceId())) {
|
||||
@@ -168,6 +174,13 @@ public class OrderApplicationService {
|
||||
}
|
||||
var cargoes = repository.findCargo(tenantId, order.getId());
|
||||
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 (!StringUtils.hasText(order.getAmazonWarehouseCode()) || !StringUtils.hasText(order.getShipmentId())
|
||||
|| !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) {
|
||||
OrderEntity order = repository.findOwned(tenantId, id);
|
||||
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) {
|
||||
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.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
|
||||
: snapshots.destination().warehouse().countryCode());
|
||||
@@ -324,8 +343,8 @@ public class OrderApplicationService {
|
||||
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
|
||||
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
|
||||
}
|
||||
saveParty(tenantId, userId, orderId, "SENDER", request.sender());
|
||||
saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver());
|
||||
if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
|
||||
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) {
|
||||
OrderPartyEntity item = new OrderPartyEntity();
|
||||
|
||||
@@ -28,9 +28,8 @@ public class OrderMasterDataGuard {
|
||||
this.warehouses=warehouses; this.partners=partners; this.token=token;
|
||||
}
|
||||
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
|
||||
if (request.customerId() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "必须选择往来单位");
|
||||
try {
|
||||
var partner=loadPartner(tenantId,request.customerId());
|
||||
PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(tenantId,request.customerId());
|
||||
var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库");
|
||||
requireWarehouseType(receiving, "DOMESTIC", "收货仓库");
|
||||
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,
|
||||
Long destinationWarehouseId, String destinationType) {
|
||||
try {
|
||||
if (partnerId==null) throw new BizException(ResultCode.NOT_FOUND,"往来单位已停用或不可用");
|
||||
loadPartner(tenantId,partnerId);
|
||||
if (partnerId!=null) loadPartner(tenantId,partnerId);
|
||||
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
|
||||
WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库");
|
||||
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 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;}
|
||||
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:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
/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:
|
||||
summary: 平台代客创建订单草稿
|
||||
parameters:
|
||||
|
||||
@@ -24,7 +24,7 @@ class OrderControllerPermissionTest {
|
||||
@Test
|
||||
void platformOrderListAndDetailUseConfiguredListPermission() throws Exception {
|
||||
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')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
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.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -98,6 +99,68 @@ class OrderApplicationServiceStateTest {
|
||||
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,
|
||||
OrderMasterDataGuard masterData) {
|
||||
OrderApplicationService service = new OrderApplicationService();
|
||||
@@ -121,12 +184,44 @@ class OrderApplicationServiceStateTest {
|
||||
"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() {
|
||||
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 address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null);
|
||||
var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0));
|
||||
var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability));
|
||||
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);
|
||||
}
|
||||
@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() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user