This commit is contained in:
2026-08-05 16:26:10 +08:00
parent 06f7f9d2f2
commit 5ba9716e12
155 changed files with 2883 additions and 551 deletions
+6 -5
View File
@@ -17,7 +17,7 @@
| 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` |
| 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java`;往来单位响应同时包含多值 `types` 与兼容字段 `partnerType` |
| 租户与访问范围 | `service/OrderScopeGuard.java` |
| 客户归属与访问范围 | `service/OrderScopeGuard.java`;类内 `tenant` 命名为历史兼容 |
| 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` |
| 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` |
| 表结构与一期扩展 | `src/main/resources/db/order.sql`、`phase1-masterdata-extension.sql`、`phase1-order-extension.sql` |
@@ -26,16 +26,17 @@
## 修改与验证
- 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。
- 所有商户查询和写入必须执行客户归属/访问范围校验;业务表 `tenant_id` 当前承载客户 ID。平台操作必须验证平台技术作用域、目标客户、权限及合法状态迁移。
- 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。
- 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。
- 客户列表中的真实运单数归 Order,不得写回 IAM。`GET /api/platform/v1/orders/first-leg/tenant-counts?tenantIds=...` 仅允许平台 `platform:order:list`,一次接收 1 至 200 个有效租户 ID,并对没有订单的租户返回 0。
- 客户列表中的真实运单数归 Order,不得写回 IAM。历史接口 `GET /api/platform/v1/orders/first-leg/tenant-counts?tenantIds=...` 仅允许平台 `platform:order:list`,一次接收 1 至 200 个有效客户 ID,并对没有订单的客户返回 0;路径和参数名保持兼容。
- Warehouse 往来单位已支持多类型;下单校验优先使用 `PartnerClient.Partner.types`,`partnerType` 只作为旧数据兼容。类型语义变化需同步 Warehouse 的 `wh_partner_type`、Order 客户端契约与快照测试。
- 往来单位是平台统一维护、供所有客户共享的公共主数据,Order 调用 Warehouse 内部接口时固定使用公共作用域 `tenant_id=0`;订单自己的 `tenant_id` 仍是客户隔离键,不能拿来查询往来单位。
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
- 二期第三方测量绑定订单货物时,Firstleg 只能通过 `InternalOrderCargoController` 校验 `tenantId + orderId + cargoId` 并读取必要快照;该接口必须保留内部令牌校验,不能暴露到 Gateway,也不能返回 Order Entity。
- Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按客户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 HTTP 200 + `S0500`,真实原因只记录在服务日志。
- 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。
- 普通业务失败使用 HTTP 200 + body code;Feign 收到 HTTP 非 200 代表鉴权、协议或基础设施异常,应归一为 `S0503`,不能用 Feign 状态码推断 404/409 业务语义。
@@ -45,5 +46,5 @@
- `POST /api/platform/v1/orders/first-leg?tenantId={tenantId}`:平台代客创建草稿,校验 `platform:order:create`,必须携带 `Idempotency-Key`。
- `PUT /api/platform/v1/orders/first-leg/{id}?tenantId={tenantId}`:平台编辑草稿,校验 `platform:order:edit`。
- `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。
- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以租户、操作人、操作和幂等键建立唯一约束。
- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以客户隔离兼容字段、操作人、操作和幂等键建立唯一约束。
- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。
+4 -3
View File
@@ -5,16 +5,17 @@
- `baiyue_order`:订单主数据、货物、收发货快照、目的仓快照、内部备注、状态历史和事务发件箱。 / Order master data, cargo, sender/receiver and destination snapshots, internal notes, status history, and transactional outbox.
- `baiyue_firstleg`:头程履约任务、指派历史与事件消费幂等。 / First-leg fulfillment tasks, assignment history, and event-consumer idempotency.
- 两个服务仅通过 `tenant_id + order_id + order_no` 逻辑关联,不建立跨库外键。 / Services use logical `tenant_id + order_id + order_no` references and never create cross-database foreign keys.
- 当前 `tenant_id` 是客户归属与隔离兼容字段;接口、表和类型中的 `tenant` 命名保持兼容,不表示真实上级租户。`tenant_id=0` 只用于平台技术作用域,客户订单必须使用大于 0 的客户 ID。
## 接口隔离 / API separation
- 商户端 `/api/merchant/v1/orders/first-leg`:草稿、编辑、提交、查询、详情、取消。 / Merchant draft, edit, submit, query, detail, and cancel APIs.
- 平台端 `/api/platform/v1/orders/first-leg`:跨租户订单列表、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform cross-tenant list, detail, review, rejection, acceptance, cancellation, processing, completion, and internal notes.
- 平台端 `/api/platform/v1/orders/first-leg`:跨客户订单列表、详情、审核、驳回、受理、取消、开始处理、完成、内部备注。 / Platform cross-customer 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` 时查询全部客户租户,传入时按单个租户筛选;平台详情和写操作仍必须显式传入目标 `tenantId`。
商户接口要求 `appCode=MERCHANT`,客户归属从 JWT 的兼容字段取得。平台列表接口要求 `appCode=PLATFORM`,省略 `tenantId` 时查询全部客户,传入时按单个客户筛选;平台详情和写操作仍必须显式传入代表目标客户的 `tenantId`。
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`.
Merchant APIs require `appCode=MERCHANT` and derive the customer scope from the JWT compatibility field. The platform list requires `appCode=PLATFORM`: omitting `tenantId` queries all customers, while supplying it filters one customer. Platform detail and write operations still require an explicit `tenantId` representing the target customer.
## 状态与审计 / State and audit
@@ -34,7 +34,12 @@ public class OrderRepository {
@Resource private OrderIdempotencyRecordMapper idempotencyMapper;
public void insertOrder(OrderEntity order) { orderMapper.insert(order); }
public int saveOrder(OrderEntity order) { return orderMapper.updateById(order); }
public int saveOrder(long tenantId, OrderEntity order) {
if (order.getTenantId() == null || order.getTenantId() != tenantId) return 0;
return orderMapper.update(order, new LambdaUpdateWrapper<OrderEntity>()
.eq(OrderEntity::getId, order.getId())
.eq(OrderEntity::getTenantId, tenantId));
}
public void insertCargo(OrderCargoEntity cargo) { cargoMapper.insert(cargo); }
public void insertParty(OrderPartyEntity party) { partyMapper.insert(party); }
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
@@ -42,27 +47,38 @@ public class OrderRepository {
public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); }
public List<OutboxEventEntity> pendingInboundEvents(int limit) {
return outboxMapper.selectList(new LambdaQueryWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getEventType, "FIRST_LEG_ORDER_APPROVED")
.eq(OutboxEventEntity::getAggregateType, "FIRST_LEG_ORDER")
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.and(v -> v.isNull(OutboxEventEntity::getNextRetryAt)
.or().le(OutboxEventEntity::getNextRetryAt, LocalDateTime.now()))
.orderByAsc(OutboxEventEntity::getCreatedAt)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public void markOutboxPublished(long id, LocalDateTime publishedAt) {
public void markOutboxPublished(long tenantId, long id, LocalDateTime publishedAt) {
outboxMapper.update(null, new LambdaUpdateWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getId, id)
.eq(OutboxEventEntity::getTenantId, tenantId)
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.set(OutboxEventEntity::getPublishStatus, "PUBLISHED")
.set(OutboxEventEntity::getPublishedAt, publishedAt));
}
public void markOutboxRetry(long id, LocalDateTime nextRetryAt) {
public void markOutboxRetry(long tenantId, long id, LocalDateTime nextRetryAt) {
outboxMapper.update(null, new LambdaUpdateWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getId, id)
.eq(OutboxEventEntity::getTenantId, tenantId)
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.setSql("retry_count = retry_count + 1")
.set(OutboxEventEntity::getNextRetryAt, nextRetryAt));
}
public void markOutboxFailed(Long tenantId, long id) {
outboxMapper.update(null, new LambdaUpdateWrapper<OutboxEventEntity>()
.eq(OutboxEventEntity::getId, id)
.eq(tenantId != null, OutboxEventEntity::getTenantId, tenantId)
.isNull(tenantId == null, OutboxEventEntity::getTenantId)
.eq(OutboxEventEntity::getPublishStatus, "PENDING")
.set(OutboxEventEntity::getPublishStatus, "FAILED")
.set(OutboxEventEntity::getNextRetryAt, null));
}
public void insertIdempotency(OrderIdempotencyRecordEntity record) { idempotencyMapper.insert(record); }
public OrderIdempotencyRecordEntity findIdempotency(
@@ -74,11 +90,15 @@ public class OrderRepository {
.eq(OrderIdempotencyRecordEntity::getIdempotencyKey, idempotencyKey));
}
public int completeIdempotency(OrderIdempotencyRecordEntity record, Long resultOrderId) {
public int completeIdempotency(long tenantId, OrderIdempotencyRecordEntity record, Long resultOrderId) {
if (record.getTenantId() == null || record.getTenantId() != tenantId) return 0;
record.setStatus("COMPLETED");
record.setResultOrderId(resultOrderId);
record.setCompletedAt(java.time.LocalDateTime.now());
return idempotencyMapper.updateById(record);
return idempotencyMapper.update(record, new LambdaUpdateWrapper<OrderIdempotencyRecordEntity>()
.eq(OrderIdempotencyRecordEntity::getId, record.getId())
.eq(OrderIdempotencyRecordEntity::getTenantId, tenantId)
.eq(OrderIdempotencyRecordEntity::getStatus, "PROCESSING"));
}
public OrderEntity findOwned(long tenantId, long orderId) {
@@ -67,7 +67,7 @@ public class OrderApplicationService {
throw new BizException("Only draft or rejected orders can be edited");
}
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update");
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
repository.replaceDraftChildren(tenantId, id);
saveChildren(tenantId, userId, id, request);
}
@@ -90,7 +90,7 @@ public class OrderApplicationService {
order.setStatus(target); order.setUpdatedBy(userId);
if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now());
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
if (repository.saveOrder(order) != 1) throw new BizException("Concurrent order update");
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
record(tenantId, id, from, target, target.name(), reason, userId, username);
if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target);
}
@@ -142,7 +142,7 @@ public class OrderApplicationService {
order.setExpressCompany(request.expressCompany().trim());
order.setExpressTrackingNo(request.expressTrackingNo().trim());
order.setUpdatedBy(userId);
if (repository.saveOrder(order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改");
if (repository.saveOrder(tenantId, order) != 1) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订单已被并发修改");
}
private void validatePrototypeFields(OrderRequests.SaveRequest request) {
@@ -248,7 +248,7 @@ public class OrderApplicationService {
private void completeIdempotency(IdempotencyContext context, Long resultOrderId) {
if (context.enabled() && !context.replayed()) {
if (repository.completeIdempotency(context.record(), resultOrderId) != 1) {
if (repository.completeIdempotency(context.record().getTenantId(), context.record(), resultOrderId) != 1) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"幂等记录已被并发修改");
}
@@ -7,6 +7,7 @@ import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -15,6 +16,7 @@ import java.time.LocalDateTime;
/** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */
@Component
@Slf4j
public class OrderInboundOutboxPublisher {
@Resource private OrderRepository repository;
@Resource private FirstlegInboundClient client;
@@ -24,17 +26,40 @@ public class OrderInboundOutboxPublisher {
@Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}")
public void publishApprovedOrders() {
for (OutboxEventEntity event : repository.pendingInboundEvents(50)) {
if (!"FIRST_LEG_ORDER".equals(event.getAggregateType())
|| !"FIRST_LEG_ORDER_APPROVED".equals(event.getEventType())) {
failPermanently(event, "unsupported order event");
continue;
}
FirstlegInboundClient.OrderApprovedInboundCommand command;
try {
FirstlegInboundClient.OrderApprovedInboundCommand command = json.readValue(
command = json.readValue(
event.getPayloadJson(), FirstlegInboundClient.OrderApprovedInboundCommand.class);
} catch (Exception exception) {
failPermanently(event, "invalid event payload");
continue;
}
if (event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId()
|| event.getAggregateId() != command.orderId()) {
failPermanently(event, "event ownership does not match payload");
continue;
}
try {
ApiResponse<Long> response = client.create(internalToken, command);
if (response == null || !response.successful()) {
throw new IllegalStateException("头程入库服务返回失败");
}
repository.markOutboxPublished(event.getId(), LocalDateTime.now());
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now());
} catch (Exception exception) {
repository.markOutboxRetry(event.getId(), LocalDateTime.now().plusSeconds(10));
repository.markOutboxRetry(event.getTenantId(), event.getId(), LocalDateTime.now().plusSeconds(10));
}
}
}
private void failPermanently(OutboxEventEntity event, String reason) {
log.warn("Order outbox event rejected: reason={}, eventId={}, tenantId={}, aggregateId={}, eventType={}",
reason, event.getId(), event.getTenantId(), event.getAggregateId(), event.getEventType());
repository.markOutboxFailed(event.getTenantId(), event.getId());
}
}
@@ -23,11 +23,11 @@ public class OrderMasterDataGuard {
@Resource private WarehouseClient warehouses;
@Resource private PartnerClient partners;
@Value("${baiyue.security.internal-service-token}") private String token;
@Value("${baiyue.warehouse.public-tenant-id:10001}")
private long publicTenantId = 10001L;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId = 0L;
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
try {
PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(tenantId,request.customerId());
PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(request.customerId());
var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库");
requireWarehouseType(receiving, "DOMESTIC", "收货仓库");
if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull))
@@ -59,7 +59,7 @@ public class OrderMasterDataGuard {
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId,
Long destinationWarehouseId, String destinationType) {
try {
if (partnerId!=null) loadPartner(tenantId,partnerId);
if (partnerId!=null) loadPartner(partnerId);
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库");
requireWarehouseType(receiving,"DOMESTIC","收货仓库");
@@ -78,18 +78,18 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
private PartnerClient.Partner loadPartner(long tenantId,long id) {
private PartnerClient.Partner loadPartner(long id) {
ApiResponse<PartnerClient.Partner> response;
try {
response=partners.active(token,tenantId,id);
response=partners.active(token,publicTenantId,id);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"\u672a\u7ed1\u5b9a\u53ef\u7528\u5f80\u6765\u5ba2\u6237");
throw new BizException(ResultCode.NOT_FOUND,"平台往来单位不存在或不可用");
}
requireSuccessful(response,"往来单位");
if (response.data()==null) throw unavailable("往来单位");
var value=response.data();
if (!Long.valueOf(tenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业");
if (!Long.valueOf(publicTenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于平台公共主数据");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
if (value.types() == null || !value.types().contains("SHIPPER"))
@@ -5,7 +5,8 @@ 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;}
public static void platform(AuthenticatedUser u){requireApp(u,"PLATFORM");requirePlatformTenant(u);}
public static long platform(AuthenticatedUser u,long targetTenantId){platform(u);if(targetTenantId<=0)throw new BizException(ResultCode.BAD_REQUEST,"Target tenant is required");return targetTenantId;}
private static void requireApp(AuthenticatedUser u,String expected){if(u==null||!expected.equals(u.appCode()))throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope");}
private static void requirePlatformTenant(AuthenticatedUser u){if(u.tenantId()==null||u.tenantId()!=0)throw new BizException(ResultCode.FORBIDDEN,"Platform tenant must be 0");}
}
@@ -9,7 +9,7 @@ paths:
/api/merchant/v1/orders/first-leg:
get:
summary: 客户端入库订单聚合列表
description: 按当前登录租户分页返回订单、货物汇总、收货方快照及头程入仓编码,需要 merchant:order:list 权限。
description: 按当前登录客户分页返回订单、货物汇总、收货方快照及头程入仓编码,需要 merchant:order:list 权限;客户 ID 从 JWT 的 tenantId 兼容字段取得。
parameters:
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}
- {name: orderNo, in: query, required: false, schema: {type: string, maxLength: 64}}
@@ -53,12 +53,12 @@ paths:
/api/platform/v1/orders/first-leg:
get:
summary: 平台分页查询头程订单
description: tenantId 省略时查询全部客户租户订单,传入时仅查询指定租户。
description: tenantId 是目标客户 ID 的兼容参数名;省略时查询全部客户订单,传入时仅查询指定客户。
parameters:
- name: tenantId
in: query
required: false
description: 可选的目标租户筛选
description: 可选的目标客户筛选;tenantId 为历史兼容参数名
schema: {type: integer, format: int64, minimum: 1}
responses:
'200': {description: 查询成功或业务失败,按响应体 code 判断}
@@ -79,7 +79,7 @@ paths:
/api/platform/v1/orders/first-leg/tenant-counts:
get:
summary: 批量查询客户运单数
description: 按客户租户 ID 返回真实头程运单数量,最多 200 个租户。
description: 按客户 ID 返回真实头程运单数量,tenantIds 为历史兼容参数名;最多 200 个客户。
parameters:
- name: tenantIds
in: query
@@ -133,5 +133,5 @@ components:
name: Idempotency-Key
in: header
required: true
description: 同一租户和操作者重试同一请求时必须保持不变
description: 同一客户和操作者重试同一请求时必须保持不变
schema: {type: string, minLength: 1, maxLength: 128}
@@ -13,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat;
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.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalOrderCargoController.class)
@@ -45,4 +46,16 @@ class InternalOrderCargoControllerTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
@Test
void cargoFromAnotherCustomerIsNotExposed() {
when(orders.findCargo(12L, 21L, 31L)).thenReturn(null);
assertThatThrownBy(() -> controller.detail("internal-token", 12L, 21L, 31L))
.isInstanceOf(BizException.class)
.hasMessageContaining("不存在或不属于该订单");
verify(orders).findCargo(12L, 21L, 31L);
verifyNoMoreInteractions(orders);
}
}
@@ -27,7 +27,7 @@ class PlatformOrderPageControllerTest {
@Test
void omittedTenantQueriesAcrossAllTenantsForPlatformUser() {
controller.page(user("PLATFORM"), null, query);
controller.page(user("PLATFORM", 0L), null, query);
verify(service).page(null, query);
}
@@ -35,7 +35,7 @@ class PlatformOrderPageControllerTest {
@Test
void suppliedTenantFiltersOneTenantForPlatformUser() {
controller.page(user("PLATFORM"), 2084455952401629185L, query);
controller.page(user("PLATFORM", 0L), 2084455952401629185L, query);
verify(service).page(2084455952401629185L, query);
}
@@ -43,7 +43,7 @@ class PlatformOrderPageControllerTest {
@Test
void invalidTenantFilterIsRejectedBeforeQuery() {
assertThatThrownBy(() -> controller.page(user("PLATFORM"), 0L, query))
assertThatThrownBy(() -> controller.page(user("PLATFORM", 0L), 0L, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Target tenant is required");
verifyNoInteractions(service);
@@ -52,13 +52,21 @@ class PlatformOrderPageControllerTest {
@Test
void merchantCannotUseCrossTenantPlatformList() {
assertThatThrownBy(() -> controller.page(user("MERCHANT"), null, query))
assertThatThrownBy(() -> controller.page(user("MERCHANT", 10001L), null, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Wrong application scope");
verifyNoInteractions(service);
}
private AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(1L, null, null, null, "operator", appCode, "WEB", Set.of());
@Test
void platformTokenWithCustomerTenantIsRejected() {
assertThatThrownBy(() -> controller.page(user("PLATFORM", 10001L), null, query))
.isInstanceOf(BizException.class)
.hasMessageContaining("Platform tenant must be 0");
verifyNoInteractions(service);
}
private AuthenticatedUser user(String appCode, Long tenantId) {
return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of());
}
}
@@ -0,0 +1,149 @@
package com.baiyue.order.repository;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.mapper.OrderIdempotencyRecordMapper;
import com.baiyue.order.mapper.OrderMapper;
import com.baiyue.order.mapper.OutboxEventMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class OrderRepositoryIsolationUpdateTest {
@BeforeAll
static void initializeTableMetadata() {
initialize(OrderEntity.class, "order-update-test");
initialize(OrderIdempotencyRecordEntity.class, "idempotency-update-test");
initialize(OutboxEventEntity.class, "outbox-update-test");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void orderUpdateIsLimitedByCustomerAndOrderId() {
OrderMapper mapper = mock(OrderMapper.class);
OrderRepository repository = repository("orderMapper", mapper);
OrderEntity order = new OrderEntity();
order.setId(21L);
order.setTenantId(11L);
when(mapper.update(any(OrderEntity.class), any(Wrapper.class))).thenReturn(1);
repository.saveOrder(11L, order);
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).update(any(OrderEntity.class), wrapper.capture());
assertScope(wrapper.getValue(), 11L, 21L);
}
@Test
void mismatchedEntityCustomerCannotBeMovedByUpdate() {
OrderMapper mapper = mock(OrderMapper.class);
OrderRepository repository = repository("orderMapper", mapper);
OrderEntity order = new OrderEntity();
order.setId(21L);
order.setTenantId(12L);
assertThat(repository.saveOrder(11L, order)).isZero();
verifyNoInteractions(mapper);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void idempotencyCompletionIsLimitedByCustomerRecordAndProcessingState() {
OrderIdempotencyRecordMapper mapper = mock(OrderIdempotencyRecordMapper.class);
OrderRepository repository = repository("idempotencyMapper", mapper);
OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity();
record.setId(31L);
record.setTenantId(11L);
record.setStatus("PROCESSING");
when(mapper.update(any(OrderIdempotencyRecordEntity.class), any(Wrapper.class))).thenReturn(1);
repository.completeIdempotency(11L, record, 21L);
ArgumentCaptor<Wrapper<OrderIdempotencyRecordEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).update(any(OrderIdempotencyRecordEntity.class), wrapper.capture());
assertScope(wrapper.getValue(), 11L, 31L);
assertThat(wrapper.getValue().getSqlSegment()).contains("status");
assertThat(params(wrapper.getValue()).values()).contains("PROCESSING");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void everyOutboxStateUpdateIsLimitedByCustomerEventAndPendingState() {
OutboxEventMapper mapper = mock(OutboxEventMapper.class);
OrderRepository repository = repository("outboxMapper", mapper);
repository.markOutboxPublished(11L, 41L, LocalDateTime.now());
repository.markOutboxRetry(11L, 42L, LocalDateTime.now());
repository.markOutboxFailed(11L, 43L);
ArgumentCaptor<Wrapper<OutboxEventEntity>> wrappers = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper, times(3)).update(isNull(), wrappers.capture());
List<Wrapper<OutboxEventEntity>> values = wrappers.getAllValues();
assertScope(values.get(0), 11L, 41L);
assertScope(values.get(1), 11L, 42L);
assertScope(values.get(2), 11L, 43L);
assertThat(values).allSatisfy(value -> {
assertThat(value.getSqlSegment()).contains("publish_status");
assertThat(params(value).values()).contains("PENDING");
});
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void pendingOrderEventsIncludeCancellationForTerminalHandling() {
OutboxEventMapper mapper = mock(OutboxEventMapper.class);
OrderRepository repository = repository("outboxMapper", mapper);
when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of());
repository.pendingInboundEvents(50);
ArgumentCaptor<Wrapper<OutboxEventEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).selectList(wrapper.capture());
assertThat(wrapper.getValue().getSqlSegment())
.contains("aggregate_type", "publish_status")
.doesNotContain("event_type");
assertThat(params(wrapper.getValue()).values())
.contains("FIRST_LEG_ORDER", "PENDING");
}
private static <T> void initialize(Class<T> entityType, String resource) {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), resource), entityType);
}
private OrderRepository repository(String field, Object mapper) {
OrderRepository repository = new OrderRepository();
ReflectionTestUtils.setField(repository, field, mapper);
return repository;
}
private void assertScope(Wrapper<?> wrapper, long tenantId, long id) {
assertThat(wrapper.getSqlSegment()).contains("tenant_id", "id");
assertThat(params(wrapper).values()).contains(tenantId, id);
}
@SuppressWarnings("rawtypes")
private static java.util.Map<String, Object> params(Wrapper<?> wrapper) {
return ((AbstractWrapper) wrapper).getParamNameValuePairs();
}
}
@@ -47,7 +47,7 @@ class OrderApplicationServiceStateTest {
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request();
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(any(), anyLong())).thenReturn(1);
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
Long orderId = service(repository, eligibility, masterData)
.createDraft(10001L, 2L, request, "draft-before-approval");
@@ -69,13 +69,13 @@ class OrderApplicationServiceStateTest {
order.setStatus(OrderStatus.DRAFT);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(order)).thenReturn(1);
when(repository.saveOrder(10001L, order)).thenReturn(1);
service(repository, eligibility, masterData).updateDraft(10001L, 2L, 1L, request);
verify(eligibility, never()).requireApproved(anyLong());
verify(repository).replaceDraftChildren(10001L, 1L);
verify(repository).saveOrder(argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
}
@Test
@@ -97,7 +97,7 @@ class OrderApplicationServiceStateTest {
.isEqualTo(ResultCode.FORBIDDEN);
verify(eligibility).requireApproved(10001L);
verify(repository, never()).saveOrder(any());
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
@@ -113,7 +113,7 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service,"json",new ObjectMapper());
assertThatThrownBy(() -> service.transit(10001,2,"u",1,OrderStatus.SUBMITTED,null))
.isInstanceOf(BizException.class).hasMessageContaining("重复提交");
verify(repository,never()).saveOrder(any());
verify(repository,never()).saveOrder(anyLong(), any());
}
@Test
@@ -123,7 +123,7 @@ class OrderApplicationServiceStateTest {
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);
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, eligibility, masterData)
.createDraft(20002L, 2L, request, "public-destination-no-parties");
@@ -175,7 +175,21 @@ class OrderApplicationServiceStateTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("上门提货时必须填写发货信息");
verify(repository, never()).saveOrder(any());
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void updatingAnotherCustomersOrderStopsBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)).updateDraft(20002L, 2L, 1L, request()))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verify(repository).findOwned(20002L, 1L);
verify(repository, never()).saveOrder(anyLong(), any());
}
private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility,
@@ -215,11 +229,11 @@ class OrderApplicationServiceStateTest {
}
private OrderMasterDataGuard.Snapshots snapshots() {
return snapshots(new PartnerClient.Partner(7L, 10001L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER")));
return snapshots(new PartnerClient.Partner(7L, 0L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER")));
}
private OrderMasterDataGuard.Snapshots snapshots(PartnerClient.Partner partner) {
var warehouse = new WarehouseClient.Warehouse(8L, 10001L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE");
var warehouse = new WarehouseClient.Warehouse(8L, 0L, "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));
@@ -118,7 +118,7 @@ class OrderIdempotencyServiceTest {
service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key");
verify(repository, never()).findOwned(anyLong(), anyLong());
verify(repository, never()).saveOrder(any());
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
@@ -0,0 +1,123 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class OrderInboundOutboxPublisherTest {
@Test
void matchingApprovedEventIsDeliveredAndPublished() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L)));
when(fixture.client.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L));
fixture.publisher.publishApprovedOrders();
verify(fixture.client).create(eq("internal-token"), any());
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any());
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
}
@Test
void mismatchedCustomerIsPermanentlyFailedBeforeDelivery() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(12L, 21L)));
fixture.publisher.publishApprovedOrders();
verifyNoInteractions(fixture.client);
verify(fixture.repository).markOutboxFailed(11L, 41L);
verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any());
}
@Test
void mismatchedAggregateIsPermanentlyFailedBeforeDelivery() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 22L)));
fixture.publisher.publishApprovedOrders();
verifyNoInteractions(fixture.client);
verify(fixture.repository).markOutboxFailed(11L, 41L);
}
@Test
void unsupportedOrderEventReachesFailedTerminalState() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_CANCELLED", 11L, 21L, payload(11L, 21L)));
fixture.publisher.publishApprovedOrders();
verifyNoInteractions(fixture.client);
verify(fixture.repository).markOutboxFailed(11L, 41L);
verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any());
}
@Test
void unknownOrderEventReachesFailedTerminalState() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_UNKNOWN", 11L, 21L, payload(11L, 21L)));
fixture.publisher.publishApprovedOrders();
verifyNoInteractions(fixture.client);
verify(fixture.repository).markOutboxFailed(11L, 41L);
}
@Test
void transientFirstlegFailureRemainsPendingForRetry() {
Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(11L, 21L)));
when(fixture.client.create(eq("internal-token"), any()))
.thenThrow(new IllegalStateException("temporary failure"));
fixture.publisher.publishApprovedOrders();
verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any());
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
}
private Fixture fixture(OutboxEventEntity event) {
OrderRepository repository = mock(OrderRepository.class);
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher();
ReflectionTestUtils.setField(publisher, "repository", repository);
ReflectionTestUtils.setField(publisher, "client", client);
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper());
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token");
return new Fixture(publisher, repository, client);
}
private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) {
OutboxEventEntity event = new OutboxEventEntity();
event.setId(41L);
event.setTenantId(tenantId);
event.setAggregateType("FIRST_LEG_ORDER");
event.setAggregateId(aggregateId);
event.setEventType(eventType);
event.setPayloadJson(payload);
event.setPublishStatus("PENDING");
return event;
}
private String payload(long tenantId, long orderId) {
return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId + "}";
}
private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository,
FirstlegInboundClient client) {
}
}
@@ -30,29 +30,29 @@ class OrderMasterDataGuardTest {
@MockBean private WarehouseClient warehouses;
@MockBean private PartnerClient partners;
@Resource private OrderMasterDataGuard guard;
private static final long PUBLIC_TENANT_ID = 10001L;
private static final long PUBLIC_TENANT_ID = 0L;
@Test
void validMasterDataReturnsTrustedServerSnapshot() {
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability));
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh));
var result=guard.validate(10001,request(7L,8L));
assertThat(result.partner().partnerName()).isEqualTo("服务端客户名");
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
void inactiveOrCrossTenantMasterDataIsRejected() {
when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> guard.validate(10002,request(7L,8L)))
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
}
@Test
void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() {
when(partners.active("token",10001,7)).thenReturn(
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(
ApiResponse.<PartnerClient.Partner>failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
@@ -64,11 +64,11 @@ class OrderMasterDataGuardTest {
}
@Test
void masterDataResponseWithNullElementReturnsControlledBusinessError() {
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),
var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),
Collections.singletonList(null), Collections.singletonList(null));
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -77,7 +77,7 @@ class OrderMasterDataGuardTest {
}
@Test
void masterDataResponseMissingSnapshotNameIsServiceUnavailable() {
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,null,"ACTIVE","SHIPPER",List.of("SHIPPER"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -87,7 +87,7 @@ class OrderMasterDataGuardTest {
}
@Test
void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() {
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败"));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("解码失败"));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -96,7 +96,7 @@ class OrderMasterDataGuardTest {
}
@Test
void downstreamForbiddenMapsToServiceUnavailable() {
when(partners.active("token",10001,7)).thenThrow(feign(403));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(403));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -104,18 +104,18 @@ class OrderMasterDataGuardTest {
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamHttpNotFoundReturnsUnboundCustomerMessage() {
when(partners.active("token",10001,7)).thenThrow(feign(404));
void downstreamHttpNotFoundReturnsPlatformPartnerUnavailableMessage() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(404));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.hasMessage("\u672a\u7ed1\u5b9a\u53ef\u7528\u5f80\u6765\u5ba2\u6237")
.hasMessage("平台往来单位不存在或不可用")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
}
@Test
void wrappedDownstreamHttpConflictMapsToServiceUnavailable() {
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("wrapped", feign(409)));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("wrapped", feign(409)));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -124,7 +124,7 @@ class OrderMasterDataGuardTest {
}
@Test
void downstreamServerErrorMapsToServiceUnavailable() {
when(partners.active("token",10001,7)).thenThrow(feign(500));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(500));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -133,8 +133,8 @@ class OrderMasterDataGuardTest {
}
@Test
void inactiveMasterDataInSuccessfulResponseMapsToConflict() {
when(partners.active("token",10001,7))
.thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE","SHIPPER",List.of("SHIPPER"))));
when(partners.active("token",PUBLIC_TENANT_ID,7))
.thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","INACTIVE","SHIPPER",List.of("SHIPPER"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -143,8 +143,8 @@ class OrderMasterDataGuardTest {
}
@Test
void partnerWithoutShipperTypeIsRejected() {
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,10001L,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE"))));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -154,8 +154,8 @@ class OrderMasterDataGuardTest {
}
@Test
void customerTenantCanUseDomesticWarehouseFromPublicTenant() {
when(partners.active("token",20002,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
@@ -164,6 +164,19 @@ class OrderMasterDataGuardTest {
verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void customersAAndBUseTheSamePlatformWarehouseAndPartner() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"平台共享单位","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
guard.validate(20002,request(7L,8L));
guard.validate(30003,request(7L,8L));
verify(partners,times(2)).active("token",PUBLIC_TENANT_ID,7);
verify(warehouses,times(2)).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() {
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
@@ -177,8 +190,8 @@ class OrderMasterDataGuardTest {
}
@Test
void fbaDestinationMustReferenceMatchingPublicWarehouse() {
when(partners.active("token",20002,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
when(warehouses.active("token",PUBLIC_TENANT_ID,9)).thenReturn(ApiResponse.success(
@@ -191,8 +204,8 @@ class OrderMasterDataGuardTest {
}
@Test
void privateDestinationCannotReferencePublicWarehouse() {
when(partners.active("token",20002,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
@@ -0,0 +1,41 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderScopeGuardTest {
@Test
void merchantRequiresPositiveCustomerIsolationKey() {
assertThat(OrderScopeGuard.merchant(user("MERCHANT", 10001L))).isEqualTo(10001L);
assertThatThrownBy(() -> OrderScopeGuard.merchant(user("MERCHANT", 0L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("Merchant tenant is required");
}
@Test
void platformRequiresZeroTenantInToken() {
OrderScopeGuard.platform(user("PLATFORM", 0L));
assertThatThrownBy(() -> OrderScopeGuard.platform(user("PLATFORM", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("Platform tenant must be 0");
assertThatThrownBy(() -> OrderScopeGuard.platform(user("PLATFORM", 10001L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("Platform tenant must be 0");
}
@Test
void validPlatformCanSelectPositiveCustomer() {
assertThat(OrderScopeGuard.platform(user("PLATFORM", 0L), 20002L)).isEqualTo(20002L);
}
private AuthenticatedUser user(String appCode, Long tenantId) {
return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of());
}
}