暂存提交
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.order.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 订单写入请求的持久化幂等记录。
|
||||
* 同一个租户、操作者、操作和幂等键只能有一条记录,请求摘要负责识别资源与请求内容变化。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("ord_idempotency_record")
|
||||
public class OrderIdempotencyRecordEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long operatorId;
|
||||
private String operation;
|
||||
private Long resourceId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private Long resultOrderId;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.order.mapper;
|
||||
|
||||
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/** 订单写入幂等记录 Mapper。 */
|
||||
public interface OrderIdempotencyRecordMapper extends BaseMapper<OrderIdempotencyRecordEntity> {
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 柏悦头程订单 API
|
||||
version: 1.1.0
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/merchant/v1/orders/first-leg:
|
||||
post:
|
||||
summary: 创建订单草稿
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 创建成功或同请求重放成功,返回相同订单ID
|
||||
'400':
|
||||
description: 缺少或使用了无效幂等键
|
||||
'409':
|
||||
description: 相同幂等键对应不同请求或请求仍在处理中
|
||||
'503':
|
||||
description: 仓库或往来单位下游服务异常
|
||||
/api/merchant/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 提交订单
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: 提交成功或同请求重放成功
|
||||
'400':
|
||||
description: 缺少或使用了无效幂等键
|
||||
'404':
|
||||
description: 订单不存在
|
||||
'409':
|
||||
description: 幂等键冲突、请求处理中或订单状态冲突
|
||||
'503':
|
||||
description: 仓库或往来单位下游服务异常
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
OrderId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: 同一租户和操作者重试同一请求时必须保持不变
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OrderControllerPermissionTest {
|
||||
@Test
|
||||
void 商户订单列表和详情具有对应查询权限码() throws Exception {
|
||||
assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')",
|
||||
AuthenticatedUser.class, OrderRequests.QueryRequest.class);
|
||||
assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')",
|
||||
AuthenticatedUser.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台订单列表和详情使用已配置的订单列表权限码() 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);
|
||||
}
|
||||
|
||||
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
|
||||
throws NoSuchMethodException {
|
||||
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
|
||||
PreAuthorize authorization = method.getAnnotation(PreAuthorize.class);
|
||||
assertThat(authorization).isNotNull();
|
||||
assertThat(authorization.value()).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OrderIdempotencyServiceTest {
|
||||
@Test
|
||||
void 同键同请求返回首次创建的订单ID() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderIdempotencyRecordEntity existing = completed(998L);
|
||||
AtomicReference<OrderIdempotencyRecordEntity> attempted = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
OrderIdempotencyRecordEntity record = invocation.getArgument(0);
|
||||
attempted.set(record);
|
||||
existing.setRequestHash(record.getRequestHash());
|
||||
throw new DuplicateKeyException("并发唯一键冲突");
|
||||
}).when(repository).insertIdempotency(any());
|
||||
when(repository.findIdempotency(10001L, 20001L, "CREATE_DRAFT", "draft-key"))
|
||||
.thenReturn(existing);
|
||||
|
||||
Long result = service(repository).createDraft(10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key");
|
||||
|
||||
assertThat(result).isEqualTo(998L);
|
||||
assertThat(attempted.get().getTenantId()).isEqualTo(10001L);
|
||||
assertThat(attempted.get().getOperatorId()).isEqualTo(20001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 同键不同请求返回冲突() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
doAnswer(invocation -> { throw new DuplicateKeyException("并发唯一键冲突"); })
|
||||
.when(repository).insertIdempotency(any());
|
||||
OrderIdempotencyRecordEntity existing = completed(998L);
|
||||
existing.setRequestHash("different-request");
|
||||
when(repository.findIdempotency(anyLong(), anyLong(), anyString(), anyString()))
|
||||
.thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).createDraft(
|
||||
10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 并发请求尚未完成时返回冲突() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderIdempotencyRecordEntity existing = new OrderIdempotencyRecordEntity();
|
||||
existing.setStatus("PROCESSING");
|
||||
doAnswer(invocation -> {
|
||||
OrderIdempotencyRecordEntity record = invocation.getArgument(0);
|
||||
existing.setRequestHash(record.getRequestHash());
|
||||
throw new DuplicateKeyException("并发唯一键冲突");
|
||||
}).when(repository).insertIdempotency(any());
|
||||
when(repository.findIdempotency(10001L, 20001L, "CREATE_DRAFT", "draft-key"))
|
||||
.thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).createDraft(
|
||||
10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 不存在订单统一返回404() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
assertThatThrownBy(() -> service(repository).detail(10001L, 404L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void SQL唯一键包含租户操作者操作和幂等键() 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 {
|
||||
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("name: Idempotency-Key")
|
||||
.contains("required: true");
|
||||
}
|
||||
|
||||
private OrderApplicationService service(OrderRepository repository) {
|
||||
OrderApplicationService service = new OrderApplicationService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "eligibilityGuard", mock(OrderEligibilityGuard.class));
|
||||
ReflectionTestUtils.setField(service, "masterDataGuard", mock(OrderMasterDataGuard.class));
|
||||
return service;
|
||||
}
|
||||
|
||||
private OrderIdempotencyRecordEntity completed(long orderId) {
|
||||
OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity();
|
||||
record.setStatus("COMPLETED");
|
||||
record.setResultOrderId(orderId);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user