From 667e0be7bdc6b572f4207f85a94bf5643855ba00 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 12:00:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/baiyue/order/dto/OrderRequests.java | 8 +- .../service/OrderApplicationService.java | 25 ++++- .../order/service/OrderMasterDataGuard.java | 6 +- .../OrderApplicationServiceStateTest.java | 97 ++++++++++++++++++- .../service/OrderMasterDataGuardTest.java | 13 +++ 5 files changed, 137 insertions(+), 12 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 9dc21d26d..6a6e8a2aa 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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 cargoes, @NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod, diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 423f6152b..ffbf990f6 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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 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(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 01a6fe9d4..b81015a2b 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -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","收货仓库"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 3a940e572..58c04218c 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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; + } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 89a72cc32..d8362fb75 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -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( From 434715b93856b5b11af425465612ad2144dfcdf6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 12:57:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- file/AGENTS.md | 1 + file/README.md | 125 ++++++++++++++++++ order/README-ORDER.md | 6 +- .../PlatformFirstLegOrderController.java | 15 ++- .../order/repository/OrderRepository.java | 4 +- .../service/OrderApplicationService.java | 2 +- .../baiyue/order/service/OrderScopeGuard.java | 1 + .../src/main/resources/openapi/order-v1.yaml | 12 ++ .../OrderControllerPermissionTest.java | 2 +- .../PlatformOrderPageControllerTest.java | 72 ++++++++++ .../PlatformOrderPageRepositoryTest.java | 56 ++++++++ .../service/PlatformOrderPageServiceTest.java | 27 ++++ 12 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 file/README.md create mode 100644 order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java create mode 100644 order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 79bec75e6..fb5758dd0 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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` | diff --git a/file/README.md b/file/README.md new file mode 100644 index 000000000..ce4ae894d --- /dev/null +++ b/file/README.md @@ -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 只通过当前进程环境变量注入,不写入仓库文件。 diff --git a/order/README-ORDER.md b/order/README-ORDER.md index 7697f320d..d4f0cdcb4 100644 --- a/order/README-ORDER.md +++ b/order/README-ORDER.md @@ -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 diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 0d2ac59c0..5e99b8065 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -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> 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") diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 278576bc0..8c1ac3081 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -73,9 +73,9 @@ public class OrderRepository { .eq(OrderEntity::getId, orderId)); } - public PageResult page(long tenantId, OrderRequests.QueryRequest query) { + public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .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()) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index ffbf990f6..2f02c1b1e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -106,7 +106,7 @@ public class OrderApplicationService { completeIdempotency(idempotency, id); } - public PageResult page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } + public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } public List countByTenantIds(List tenantIds) { var counts = repository.countByTenantIds(tenantIds); diff --git a/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java b/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java index 5ce43f5b4..fe33d3ee3 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderScopeGuard.java @@ -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");} } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index e25726b43..3284b34dd 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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: diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index e06c3e71d..24939b033 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -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')", diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java new file mode 100644 index 000000000..1ffc4573e --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -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()); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java new file mode 100644 index 000000000..08316e1d8 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java @@ -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> 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) + ); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java new file mode 100644 index 000000000..28a61ee11 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -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); + } +} From 694425690cc2cbbc179ad8273b7252961e080987 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 13:32:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- iam/AGENTS.md | 10 +- iam/README-IAM.md | 6 +- .../PlatformUserManagementController.java | 27 ++-- .../iam/dto/PlatformUserManagementDtos.java | 2 +- .../iam/repository/MemberRepository.java | 27 +++- .../PlatformUserManagementService.java | 81 ++++++------ iam/src/main/resources/openapi/iam-v1.yaml | 61 +++++++-- .../PlatformInternalUserContractTest.java | 1 + .../PlatformUserManagementServiceTest.java | 120 ++++++++---------- 9 files changed, 197 insertions(+), 138 deletions(-) diff --git a/iam/AGENTS.md b/iam/AGENTS.md index daba57927..ec558a648 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -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,12 +26,12 @@ | 持久化 | `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` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。 diff --git a/iam/README-IAM.md b/iam/README-IAM.md index a85df6285..dc356e983 100644 --- a/iam/README-IAM.md +++ b/iam/README-IAM.md @@ -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 / 企业资料 | diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java index 54bb5fbe3..c3b7c8e37 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java @@ -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(@AuthenticationPrincipal AuthenticatedUser user) { + @Operation(summary = "分页查询平台内部用户") + public ApiResponse> 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 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 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 update(@PathVariable Long membershipId, @Valid @RequestBody UpdateUserRequest request, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index a974a3a39..7c28c5dd7 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -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() { } diff --git a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java index 577a38d27..f17e7f99c 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java @@ -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() .eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt)); } - public List findAll() { - return membershipMapper.selectList(new LambdaQueryWrapper() - .orderByDesc(IamMembership::getJoinedAt)); + public PageResult pageByTenant(long tenantId, long page, long size, + String keyword, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamMembership::getTenantId, tenantId) + .eq(StringUtils.hasText(status), IamMembership::getStatus, status) + .orderByDesc(IamMembership::getJoinedAt); + if (StringUtils.hasText(keyword)) { + String normalizedKeyword = keyword.trim(); + List matchingUserIds = userMapper.selectList(new LambdaQueryWrapper() + .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) { diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 7507f3c20..2ae33bedc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -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 list() { - Map tenantMap = tenants.listMerchants().stream() - .collect(Collectors.toMap(IamTenant::getId, Function.identity())); - Map roleMap = allRoles().stream() + public PageResult page(long page, long size, String keyword, String status) { + PageResult source = members.pageByTenant( + PLATFORM_TENANT_ID, page, size, keyword, status); + Map 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 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 roleOptions = allRoles().stream() + List tenantOptions = List.of(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE")); + List 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 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 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 roleIds = validateRoles(newTenantId, request.roleIds()); List 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 tenantMap = tenants.listMerchants().stream() - .collect(Collectors.toMap(IamTenant::getId, Function.identity())); - Map roleMap = allRoles().stream() + Map 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 tenantMap, - Map roleMap) { + private UserResponse response(IamMembership membership, Map roleMap) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary account = account(user.getAuthUserId()); List roleIds = members.findRoleIds(membership.getTenantId(), membership.getId()); List 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 allRoles() { + private List platformRoles() { return roleMapper.selectList(new LambdaQueryWrapper() - .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 validateRoles(long tenantId, List 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 + " 修复一致性"); } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 418260627..7ecc1c6f2 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -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: @@ -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] diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java index 59d7b5a7a..3b528e33b 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -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, diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 25518be11..7b3294511 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -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 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 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()); + } } From cd540f9b3ef63ff9d6464f4803704e045380eb12 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 4 Aug 2026 14:15:55 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 2 +- iam/AGENTS.md | 4 ++-- .../controller/MerchantTenantController.java | 2 +- .../com/baiyue/iam/service/TenantService.java | 4 ++-- iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../baiyue/iam/service/TenantServiceTest.java | 21 +++++++++++-------- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 72c8edf2b..601ecb42b 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -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 register(@Valid @RequestBody MerchantRegisterRequest request) { return ApiResponse.success(registrationService.register(request)); } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ec558a648..b79bfdae9 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -35,7 +35,7 @@ - 平台客户档案维护使用 `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` 指定的统一迁移为准。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index e3fe3d048..e41b8daa4 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -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 submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT"))); } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 43f345701..caa60f987 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -35,7 +35,7 @@ import java.util.Set; @Service public class TenantService { private static final Set 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); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 7ecc1c6f2..a757efab6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -481,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'} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 535486d6b..66f336fdd 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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");