暂存提交

This commit is contained in:
2026-07-31 14:57:09 +08:00
parent 7a19e2f096
commit f8804fbc52
53 changed files with 3771 additions and 0 deletions
@@ -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;
}
}