暂存提交
This commit is contained in:
@@ -30,4 +30,13 @@
|
||||
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
|
||||
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
|
||||
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
|
||||
- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。
|
||||
- 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。
|
||||
|
||||
## 平台代客接口补充
|
||||
|
||||
- `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` 以租户、操作人、操作和幂等键建立唯一约束。
|
||||
- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -40,6 +41,71 @@ public class PlatformFirstLegOrderController {
|
||||
@Resource
|
||||
private OrderApplicationService service;
|
||||
|
||||
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿")
|
||||
@Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功或幂等重放成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数或幂等键无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台创建权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求处理中"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用")
|
||||
})
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:order:create')")
|
||||
public ApiResponse<Long> create(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "创建草稿幂等键,同键同请求返回首次结果", required = true)
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿")
|
||||
@Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "订单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "订单当前不可编辑"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用")
|
||||
})
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:order:edit')")
|
||||
public ApiResponse<Void> updateDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.updateDraft(scopedTenant, user.userId(), id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_SUBMIT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台代客提交订单")
|
||||
@Operation(summary = "平台代客提交订单", description = "在明确指定的目标租户内提交草稿订单,必须携带幂等键。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "提交成功或幂等重放成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数或幂等键无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台提交权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "订单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或订单状态不可提交"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用")
|
||||
})
|
||||
@PostMapping("/{id}/submit")
|
||||
@PreAuthorize("hasAuthority('platform:order:submit')")
|
||||
public ApiResponse<Void> submit(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
|
||||
@Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true)
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.submit(scopedTenant, user.userId(), user.username(), id, idempotencyKey);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
|
||||
@@ -25,6 +25,11 @@ public class OrderEligibilityGuard {
|
||||
}
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable");
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
// Feign 解码或负载契约漂移不能泄露为通用 500,统一转换为可追踪的下游不可用。
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 下单资格响应无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
|
||||
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
|
||||
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
|
||||
receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照',
|
||||
receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照',
|
||||
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
|
||||
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
|
||||
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
|
||||
@@ -31,6 +33,8 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
|
||||
shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID',
|
||||
reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID',
|
||||
express_company VARCHAR(64) NULL COMMENT '履约快递公司名称快照',
|
||||
express_tracking_no VARCHAR(128) NULL COMMENT '履约快递单号',
|
||||
submitted_at DATETIME(3) NULL COMMENT '提交时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
|
||||
@@ -28,6 +28,8 @@ DELIMITER ;
|
||||
CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'");
|
||||
CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'");
|
||||
CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'");
|
||||
CALL phase1_add_column('ord_order', 'receiving_warehouse_address', "VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照'");
|
||||
CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照'");
|
||||
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
|
||||
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
|
||||
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
|
||||
@@ -37,6 +39,8 @@ CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMEN
|
||||
CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'");
|
||||
CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'");
|
||||
CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'");
|
||||
CALL phase1_add_column('ord_order', 'express_company', "VARCHAR(64) NULL COMMENT '履约快递公司名称快照'");
|
||||
CALL phase1_add_column('ord_order', 'express_tracking_no', "VARCHAR(128) NULL COMMENT '履约快递单号'");
|
||||
|
||||
CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'");
|
||||
@@ -52,3 +56,22 @@ CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Mo
|
||||
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
|
||||
|
||||
DROP PROCEDURE phase1_add_column;
|
||||
|
||||
-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。
|
||||
CREATE TABLE IF NOT EXISTS ord_idempotency_record (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录 ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户 ID',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人 ID',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '操作类型',
|
||||
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源 ID',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
|
||||
status VARCHAR(16) NOT NULL COMMENT '处理状态',
|
||||
result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单 ID',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key),
|
||||
KEY idx_order_idempotency_created (created_at)
|
||||
) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录';
|
||||
|
||||
@@ -42,6 +42,62 @@ paths:
|
||||
description: 幂等键冲突、请求处理中或订单状态冲突
|
||||
'503':
|
||||
description: 仓库或往来单位下游服务异常
|
||||
/api/platform/v1/orders/first-leg:
|
||||
post:
|
||||
summary: 平台代客创建订单草稿
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 创建成功或同请求重放成功
|
||||
'403':
|
||||
description: 需要 platform:order:create 权限
|
||||
'409':
|
||||
description: 幂等键冲突或请求仍在处理
|
||||
/api/platform/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 平台代客提交指定订单
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: 提交成功或同请求重放成功
|
||||
'403':
|
||||
description: 需要 platform:order:submit 权限
|
||||
'404':
|
||||
description: 订单不存在
|
||||
'409':
|
||||
description: 幂等键冲突或当前订单不可提交
|
||||
/api/platform/v1/orders/first-leg/{id}:
|
||||
put:
|
||||
summary: 平台编辑指定订单草稿
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 编辑成功
|
||||
'403':
|
||||
description: 需要 platform:order:edit 权限
|
||||
'404':
|
||||
description: 订单不存在
|
||||
'409':
|
||||
description: 当前订单不可编辑
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -49,6 +105,13 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
OrderId:
|
||||
name: id
|
||||
in: path
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
@@ -9,9 +10,10 @@ import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("订单接口权限控制")
|
||||
class OrderControllerPermissionTest {
|
||||
@Test
|
||||
void 商户订单列表和详情具有对应查询权限码() throws Exception {
|
||||
void merchantOrderListAndDetailRequireCorrespondingPermissions() throws Exception {
|
||||
assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')",
|
||||
AuthenticatedUser.class, OrderRequests.QueryRequest.class);
|
||||
assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')",
|
||||
@@ -19,13 +21,23 @@ class OrderControllerPermissionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台订单列表和详情使用已配置的订单列表权限码() throws Exception {
|
||||
void platformOrderListAndDetailUseConfiguredListPermission() throws Exception {
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')",
|
||||
AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformAssistedCreateAndEditUseDedicatedPermissions() throws Exception {
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')",
|
||||
AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class);
|
||||
}
|
||||
|
||||
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
|
||||
throws NoSuchMethodException {
|
||||
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
|
||||
|
||||
@@ -5,14 +5,16 @@ import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("订单状态流转")
|
||||
class OrderApplicationServiceStateTest {
|
||||
@Test
|
||||
void 重复提交返回明确冲突且不重复写库() {
|
||||
void repeatedSubmissionReturnsConflictWithoutDuplicatePersistence() {
|
||||
OrderRepository repository=mock(OrderRepository.class);
|
||||
OrderEntity order=new OrderEntity(); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED);
|
||||
when(repository.findOwned(10001,1)).thenReturn(order);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.TenantEligibilityClient;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("订单下单资格校验")
|
||||
class OrderEligibilityGuardTest {
|
||||
|
||||
@Test
|
||||
void downstreamResponseDecodingFailureReturnsServiceUnavailable() {
|
||||
TenantEligibilityClient client = mock(TenantEligibilityClient.class);
|
||||
when(client.check("internal-token", 10001L))
|
||||
.thenThrow(new IllegalStateException("响应契约漂移"));
|
||||
|
||||
OrderEligibilityGuard guard = new OrderEligibilityGuard();
|
||||
ReflectionTestUtils.setField(guard, "client", client);
|
||||
ReflectionTestUtils.setField(guard, "internalToken", "internal-token");
|
||||
|
||||
assertThatThrownBy(() -> guard.requireApproved(10001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -21,12 +22,14 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("订单幂等处理")
|
||||
class OrderIdempotencyServiceTest {
|
||||
@Test
|
||||
void 同键同请求返回首次创建的订单ID() {
|
||||
void sameKeyAndRequestReturnsFirstCreatedOrderId() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderIdempotencyRecordEntity existing = completed(998L);
|
||||
AtomicReference<OrderIdempotencyRecordEntity> attempted = new AtomicReference<>();
|
||||
@@ -47,7 +50,7 @@ class OrderIdempotencyServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 同键不同请求返回冲突() {
|
||||
void sameKeyWithDifferentRequestReturnsConflict() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
doAnswer(invocation -> { throw new DuplicateKeyException("并发唯一键冲突"); })
|
||||
.when(repository).insertIdempotency(any());
|
||||
@@ -64,7 +67,7 @@ class OrderIdempotencyServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 并发请求尚未完成时返回冲突() {
|
||||
void inProgressConcurrentRequestReturnsConflict() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderIdempotencyRecordEntity existing = new OrderIdempotencyRecordEntity();
|
||||
existing.setStatus("PROCESSING");
|
||||
@@ -84,7 +87,25 @@ class OrderIdempotencyServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 不存在订单统一返回404() {
|
||||
void submitOrderSameKeyReplayDoesNotEnterStateTransition() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderIdempotencyRecordEntity existing = completed(998L);
|
||||
doAnswer(invocation -> {
|
||||
OrderIdempotencyRecordEntity attempted = invocation.getArgument(0);
|
||||
existing.setRequestHash(attempted.getRequestHash());
|
||||
throw new DuplicateKeyException("并发唯一键冲突");
|
||||
}).when(repository).insertIdempotency(any());
|
||||
when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key"))
|
||||
.thenReturn(existing);
|
||||
|
||||
service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key");
|
||||
|
||||
verify(repository, never()).findOwned(anyLong(), anyLong());
|
||||
verify(repository, never()).saveOrder(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrderReturnsNotFound() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).detail(10001L, 404L))
|
||||
@@ -94,23 +115,42 @@ class OrderIdempotencyServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void SQL唯一键包含租户操作者操作和幂等键() throws Exception {
|
||||
void sqlUniqueKeyContainsTenantOperatorActionAndIdempotencyKey() throws Exception {
|
||||
String sql = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains(
|
||||
"UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void OpenAPI声明创建和提交均要求幂等键() throws Exception {
|
||||
void openApiDeclaresIdempotencyKeyForCreateAndSubmit() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/order-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/merchant/v1/orders/first-leg:")
|
||||
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
|
||||
.contains("/api/platform/v1/orders/first-leg:")
|
||||
.contains("/api/platform/v1/orders/first-leg/{id}/submit:")
|
||||
.contains("platform:order:create")
|
||||
.contains("platform:order:edit")
|
||||
.contains("platform:order:submit")
|
||||
.contains("name: Idempotency-Key")
|
||||
.contains("required: true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void newAndExistingDatabaseScriptsContainActualOrderWriteFields() throws Exception {
|
||||
String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
String migration = new ClassPathResource("db/phase1-order-extension.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
for (String column : new String[] {
|
||||
"receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no"
|
||||
}) {
|
||||
assertThat(baseline).contains(column);
|
||||
assertThat(migration).contains(column);
|
||||
}
|
||||
}
|
||||
|
||||
private OrderApplicationService service(OrderRepository repository) {
|
||||
OrderApplicationService service = new OrderApplicationService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.PartnerClient;
|
||||
import com.baiyue.order.client.WarehouseClient;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
@@ -13,9 +14,10 @@ import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("订单主数据校验")
|
||||
class OrderMasterDataGuardTest {
|
||||
@Test
|
||||
void 正常校验返回服务端可信快照() {
|
||||
void validMasterDataReturnsTrustedServerSnapshot() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
|
||||
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
|
||||
@@ -28,14 +30,14 @@ class OrderMasterDataGuardTest {
|
||||
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
|
||||
}
|
||||
@Test
|
||||
void 停用或跨租户主数据被拒绝() {
|
||||
void inactiveOrCrossTenantMasterDataIsRejected() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null));
|
||||
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
|
||||
}
|
||||
@Test
|
||||
void 主数据响应含空元素时返回受控业务错误而不是空指针异常() {
|
||||
void masterDataResponseWithNullElementReturnsControlledBusinessError() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
|
||||
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),
|
||||
@@ -49,7 +51,7 @@ class OrderMasterDataGuardTest {
|
||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
}
|
||||
@Test
|
||||
void 缺失快照名称的主数据响应按服务不可用处理() {
|
||||
void masterDataResponseMissingSnapshotNameIsServiceUnavailable() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE")));
|
||||
|
||||
@@ -60,7 +62,7 @@ class OrderMasterDataGuardTest {
|
||||
verifyNoInteractions(warehouses);
|
||||
}
|
||||
@Test
|
||||
void 下游非Feign运行时异常映射为服务不可用() {
|
||||
void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user