refactor(api): 统一 HTTP 200 响应契约

This commit is contained in:
2026-08-01 10:17:20 +08:00
parent f2e8935dbc
commit cad6c37af4
45 changed files with 689 additions and 289 deletions
@@ -44,11 +44,8 @@ public class MerchantFirstLegOrderController {
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft created / 草稿创建成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求仍在处理中"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用")
})
@PostMapping
@PreAuthorize("hasAuthority('merchant:order:create')")
@@ -64,10 +61,7 @@ public class MerchantFirstLegOrderController {
@Operation(summary = "Update draft / 修改草稿", description = "Updates an editable draft owned by the authenticated merchant tenant. / 修改当前认证商户租户拥有且可编辑的订单草稿。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft updated / 草稿更新成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Order is not editable / 订单当前不可编辑")
})
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('merchant:order:edit')")
@@ -82,7 +76,6 @@ public class MerchantFirstLegOrderController {
@Operation(summary = "My order query / 我的订单查询", description = "Queries orders only in the authenticated merchant tenant with optional filters. / 仅查询当前认证商户租户下的订单,并支持条件筛选。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
})
@GetMapping
@@ -96,7 +89,6 @@ public class MerchantFirstLegOrderController {
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
})
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('merchant:order:view')")
@@ -110,10 +102,6 @@ public class MerchantFirstLegOrderController {
@Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "缺少或使用了无效幂等键"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转或幂等键冲突"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用")
})
@PostMapping("/{id}/submit")
@PreAuthorize("hasAuthority('merchant:order:submit')")
@@ -130,9 +118,6 @@ public class MerchantFirstLegOrderController {
@Operation(summary = "Cancel / 取消", description = "Cancels an order when the current lifecycle state allows merchant cancellation. / 在当前生命周期允许商户取消时取消订单。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order cancelled / 订单取消成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Cancellation reason is invalid / 取消原因无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转无效")
})
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('merchant:order:cancel')")
@@ -45,10 +45,7 @@ public class PlatformFirstLegOrderController {
@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')")
@@ -67,9 +64,6 @@ public class PlatformFirstLegOrderController {
@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')")
@@ -87,11 +81,7 @@ public class PlatformFirstLegOrderController {
@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')")
@@ -109,7 +99,6 @@ public class PlatformFirstLegOrderController {
@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 / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid tenant or query / 租户或查询参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
})
@GetMapping
@@ -125,7 +114,6 @@ public class PlatformFirstLegOrderController {
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found in target tenant / 目标租户下订单不存在")
})
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:order:list')")
@@ -202,9 +190,7 @@ public class PlatformFirstLegOrderController {
@Operation(summary = "Add internal note / 添加内部备注", description = "Adds a platform-only internal note to the target tenant order. / 为目标租户订单添加仅平台可见的内部备注。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Note added / 备注添加成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid note / 备注内容无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在")
})
@PostMapping("/{id}/internal-notes")
@PreAuthorize("hasAuthority('platform:order:note')")
@@ -16,7 +16,16 @@ public class OrderEligibilityGuard {
public void requireApproved(long tenantId) {
try {
var response = client.check(internalToken, tenantId);
if (response == null || !response.successful() || response.data() == null) {
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Unable to verify enterprise review status");
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
if (response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Unable to verify enterprise review status");
}
if (!response.data().eligible()) {
@@ -1,6 +1,7 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
@@ -39,10 +40,6 @@ public class OrderMasterDataGuard {
destination=loadWarehouse(tenantId,request.destinationWarehouseId(),"目的仓库");
}
return new Snapshots(partner,receiving,destination);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在或不属于当前企业");
} catch (FeignException.Conflict exception) {
throw new BizException(ResultCode.CONFLICT,"往来单位或仓库已停用");
} catch (FeignException exception) {
throw downstreamFailure(exception);
} catch (BizException exception) {
@@ -58,10 +55,6 @@ public class OrderMasterDataGuard {
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
loadWarehouse(tenantId,receivingWarehouseId,"收货仓库");
if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库");
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在或不属于当前企业");
} catch (FeignException.Conflict exception) {
throw new BizException(ResultCode.CONFLICT,"往来单位或仓库已停用");
} catch (FeignException exception) {
throw downstreamFailure(exception);
} catch (BizException exception) {
@@ -72,8 +65,8 @@ public class OrderMasterDataGuard {
}
private PartnerClient.Partner loadPartner(long tenantId,long id) {
var response=partners.active(token,tenantId,id);
if (response==null || !response.successful() || response.data()==null)
throw unavailable("往来单位");
requireSuccessful(response,"往来单位");
if (response.data()==null) throw unavailable("往来单位");
var value=response.data();
if (!Long.valueOf(tenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业");
@@ -84,8 +77,8 @@ public class OrderMasterDataGuard {
}
private WarehouseClient.Detail loadWarehouse(long tenantId,long id,String name) {
var response=warehouses.active(token,tenantId,id);
if (response==null || !response.successful() || response.data()==null || response.data().warehouse()==null)
throw unavailable(name);
requireSuccessful(response,name);
if (response.data()==null || response.data().warehouse()==null) throw unavailable(name);
var value=response.data();
if (!Long.valueOf(tenantId).equals(value.warehouse().tenantId()))
throw new BizException(ResultCode.FORBIDDEN,name+"不属于当前企业");
@@ -100,27 +93,19 @@ public class OrderMasterDataGuard {
return new BizException(ResultCode.SERVICE_UNAVAILABLE,name+"服务暂时不可用");
}
private BizException downstreamFailure(RuntimeException exception) {
FeignException feignException = findFeignException(exception);
if (feignException != null) {
if (feignException.status() == 404)
return new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在或不属于当前企业");
if (feignException.status() == 409)
return new BizException(ResultCode.CONFLICT,"往来单位或仓库已停用");
if (feignException.status() == 403)
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
"主数据内部服务凭证不一致,请检查 INTERNAL_SERVICE_TOKEN");
}
log.warn("订单主数据内部调用失败,已转换为 S0503,异常类型={},原因={}",
exception.getClass().getSimpleName(), exception.getMessage());
log.debug("订单主数据内部调用完整堆栈", exception);
return unavailable("主数据");
}
private FeignException findFeignException(Throwable exception) {
Throwable current = exception;
for (int depth = 0; current != null && depth < 10; depth++, current = current.getCause()) {
if (current instanceof FeignException feignException) return feignException;
private void requireSuccessful(ApiResponse<?> response, String name) {
if (response == null) throw unavailable(name);
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
return null;
}
public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving,
WarehouseClient.Detail destination) { }
+15 -56
View File
@@ -1,6 +1,7 @@
openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.1.0
security:
- bearerAuth: []
@@ -14,17 +15,9 @@ paths:
required: true
content:
application/json:
schema:
type: object
schema: {type: object}
responses:
'200':
description: 创建成功或同请求重放成功,返回相同订单ID
'400':
description: 缺少或使用了无效幂等键
'409':
description: 相同幂等键对应不同请求或请求仍在处理中
'503':
description: 仓库或往来单位下游服务异常
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/merchant/v1/orders/first-leg/{id}/submit:
post:
summary: 提交订单
@@ -32,16 +25,7 @@ paths:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
responses:
'200':
description: 提交成功或同请求重放成功
'400':
description: 缺少或使用了无效幂等键
'404':
description: 订单不存在
'409':
description: 幂等键冲突、请求处理中或订单状态冲突
'503':
description: 仓库或往来单位下游服务异常
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/platform/v1/orders/first-leg:
post:
summary: 平台代客创建订单草稿
@@ -52,15 +36,10 @@ paths:
required: true
content:
application/json:
schema:
type: object
schema: {type: object}
responses:
'200':
description: 创建成功或同请求重放成功
'403':
description: 需要 platform:order:create 权限
'409':
description: 幂等键冲突或请求仍在处理
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
/api/platform/v1/orders/first-leg/{id}/submit:
post:
summary: 平台代客提交指定订单
@@ -69,14 +48,8 @@ paths:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/IdempotencyKey'
responses:
'200':
description: 提交成功或同请求重放成功
'403':
description: 需要 platform:order:submit 权限
'404':
description: 订单不存在
'409':
description: 幂等键冲突或当前订单不可提交
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:submit 权限}
/api/platform/v1/orders/first-leg/{id}:
put:
summary: 平台编辑指定订单草稿
@@ -87,17 +60,10 @@ paths:
required: true
content:
application/json:
schema:
type: object
schema: {type: object}
responses:
'200':
description: 编辑成功
'403':
description: 需要 platform:order:edit 权限
'404':
description: 订单不存在
'409':
description: 当前订单不可编辑
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
components:
securitySchemes:
bearerAuth:
@@ -109,22 +75,15 @@ components:
name: tenantId
in: query
required: true
schema:
type: integer
format: int64
schema: {type: integer, format: int64}
OrderId:
name: id
in: path
required: true
schema:
type: integer
format: int64
schema: {type: integer, format: int64}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: 同一租户和操作者重试同一请求时必须保持不变
schema:
type: string
minLength: 1
maxLength: 128
schema: {type: string, minLength: 1, maxLength: 128}
@@ -1,5 +1,6 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.TenantEligibilityClient;
@@ -14,6 +15,24 @@ import static org.mockito.Mockito.when;
@DisplayName("订单下单资格校验")
class OrderEligibilityGuardTest {
@Test
void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() {
TenantEligibilityClient client = mock(TenantEligibilityClient.class);
when(client.check("internal-token", 10001L)).thenReturn(
ApiResponse.<TenantEligibilityClient.EligibilityResponse>failure(
ResultCode.FORBIDDEN, "企业尚未通过审核", null, null));
OrderEligibilityGuard guard = new OrderEligibilityGuard();
ReflectionTestUtils.setField(guard, "client", client);
ReflectionTestUtils.setField(guard, "internalToken", "internal-token");
assertThatThrownBy(() -> guard.requireApproved(10001L))
.isInstanceOf(BizException.class)
.hasMessage("企业尚未通过审核")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void downstreamResponseDecodingFailureReturnsServiceUnavailable() {
TenantEligibilityClient client = mock(TenantEligibilityClient.class);
@@ -134,7 +134,10 @@ class OrderIdempotencyServiceTest {
.contains("platform:order:edit")
.contains("platform:order:submit")
.contains("name: Idempotency-Key")
.contains("required: true");
.contains("required: true")
.contains("仅响应体 code 为字符串 \"200\" 时表示成功")
.doesNotContain("'409':")
.doesNotContain("'503':");
}
@Test
@@ -42,6 +42,19 @@ class OrderMasterDataGuardTest {
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
}
@Test
void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenReturn(
ApiResponse.<PartnerClient.Partner>failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.hasMessage("往来单位不存在")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verifyNoInteractions(warehouses);
}
@Test
void masterDataResponseWithNullElementReturnsControlledBusinessError() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
@@ -77,37 +90,34 @@ class OrderMasterDataGuardTest {
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamForbiddenExplainsInternalCredentialMismatch() {
void downstreamForbiddenMapsToServiceUnavailable() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenThrow(feign(403));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.satisfies(exception -> {
BizException businessException = (BizException) exception;
assertThat(businessException.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
assertThat(businessException.getMessage()).contains("内部服务凭证");
});
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamNotFoundMapsToBusinessNotFound() {
void downstreamHttpNotFoundMapsToServiceUnavailable() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenThrow(feign(404));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void wrappedDownstreamConflictMapsToBusinessConflict() {
void wrappedDownstreamHttpConflictMapsToServiceUnavailable() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("wrapped", feign(409)));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamServerErrorMapsToServiceUnavailable() {