Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
45 changed files with 689 additions and 289 deletions
Showing only changes of commit cad6c37af4 - Show all commits
@@ -33,8 +33,6 @@ public class AuditIngestController {
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid event payload / 事件载荷无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Audit storage failed / 审计存储失败")
})
@PostMapping
@ResponseStatus(HttpStatus.ACCEPTED)
@@ -29,14 +29,25 @@ public class AuthorizationLoader {
var response = client.load(internalToken, new AuthorizationSnapshotRequest(
account.getId(), account.getDefaultTenantId(), account.getDefaultMembershipId(),
account.getDefaultAppCode()));
if (response == null || !response.successful() || response.data() == null) {
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
if (code == ResultCode.BAD_REQUEST || code == ResultCode.FORBIDDEN || code == ResultCode.NOT_FOUND) {
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
}
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
}
if (response.data() == null) {
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
}
return response.data();
} catch (FeignException exception) {
if (exception.status() == 400 || exception.status() == 403 || exception.status() == 404) {
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
}
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
}
}
@@ -55,10 +55,7 @@ public class AuthController {
@SecurityRequirements
@Operation(summary = "Login / 登录", description = "Validate credentials and issue access and refresh tokens. / 校验账号密码并签发访问令牌和刷新令牌。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Login succeeded / 登录成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数错误"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Invalid username or password / 用户名或密码错误"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "Account temporarily locked / 账号暂时锁定")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Success or authentication business failure; inspect body code / 成功或认证业务失败,按响应体 code 判断"),
})
@PostMapping("/login")
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
@@ -68,8 +65,7 @@ public class AuthController {
@SecurityRequirements
@Operation(summary = "Refresh token / 刷新令牌", description = "Rotate a valid refresh token and issue a new token pair. / 轮换有效刷新令牌并签发新的令牌对。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Token refreshed / 刷新成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Refresh token invalid or expired / 刷新令牌无效或已过期")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Success or refresh-token business failure; inspect body code / 成功或刷新令牌业务失败,按响应体 code 判断")
})
@PostMapping("/refresh")
public ApiResponse<TokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
@@ -37,12 +37,24 @@ public class MerchantRegistrationService {
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
request.companyName(), request.legalPerson(), request.legalPhone(),
request.contactEmail(), request.countryCode(), request.address()));
if (response == null || !response.successful() || response.data() == null) {
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
}
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, "IAM registration failed");
}
onboarding = response.data();
} catch (FeignException exception) {
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "Enterprise already exists");
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
}
@@ -2,11 +2,13 @@ package com.baiyue.common.core.api;
import org.springframework.http.HttpStatus;
import java.util.Arrays;
/**
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
*/
public enum ResultCode {
SUCCESS("0", "success", HttpStatus.OK),
SUCCESS("200", "success", HttpStatus.OK),
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
@@ -31,4 +33,15 @@ public enum ResultCode {
public String code() { return code; }
public String message() { return message; }
public HttpStatus httpStatus() { return httpStatus; }
/**
* 将下游统一响应中的业务码还原为本地结果码。
* 未知或空业务码按通用业务错误处理,避免把契约漂移误判为成功。
*/
public static ResultCode of(String code) {
return Arrays.stream(values())
.filter(value -> value.code.equals(code))
.findFirst()
.orElse(BUSINESS_ERROR);
}
}
@@ -7,6 +7,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -16,12 +17,22 @@ class ApiResponseTest {
@Test
void createsSuccessfulResponseWithCorrelationIds() {
ApiResponse<String> response = ApiResponse.success("ok", "request-1", "trace-1");
assertThat(response.code()).isEqualTo("200");
assertThat(response.code()).isEqualTo(ResultCode.SUCCESS.code());
assertThat(response.successful()).isTrue();
assertThat(response.requestId()).isEqualTo("request-1");
assertThat(response.timestamp()).isNotNull();
}
@Test
void resolvesKnownResultCodeAndFallsBackForUnknownCode() {
ApiResponse<Void> legacy = new ApiResponse<>("0", "legacy", null, null, null, Instant.now());
assertThat(legacy.successful()).isFalse();
assertThat(ResultCode.of("A0409")).isEqualTo(ResultCode.CONFLICT);
assertThat(ResultCode.of("UNKNOWN")).isEqualTo(ResultCode.BUSINESS_ERROR);
assertThat(ResultCode.of(null)).isEqualTo(ResultCode.BUSINESS_ERROR);
}
@Test
void pageResultIsImmutableAndRejectsInvalidMetadata() {
PageResult<String> result = new PageResult<>(List.of("a"), 1, 20, 1, 1);
+4
View File
@@ -31,6 +31,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -0,0 +1,25 @@
package com.baiyue.common.web;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
/**
* Registers the common servlet response contract even when an application only scans its own
* package, such as audit-service and event-bridge-service.
*/
@AutoConfiguration
public class CommonWebAutoConfiguration {
@Bean
@ConditionalOnMissingBean(GlobalExceptionHandler.class)
GlobalExceptionHandler globalExceptionHandler() {
return new GlobalExceptionHandler();
}
@Bean
@ConditionalOnMissingBean(CorrelationIdFilter.class)
CorrelationIdFilter correlationIdFilter() {
return new CorrelationIdFilter();
}
}
@@ -5,9 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
@@ -29,6 +31,9 @@ import java.util.Map;
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@Autowired(required = false)
private MeterRegistry meterRegistry;
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
return failure(exception.getResultCode(), exception.getMessage(), request);
@@ -91,14 +96,21 @@ public class GlobalExceptionHandler {
ResultCode code = ResultCode.VALIDATION_FAILED;
ApiResponse<Map<String, String>> body = ApiResponse.of(code, code.message(), errors,
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
return ResponseEntity.status(code.httpStatus()).body(body);
recordFailure(code);
return ResponseEntity.ok(body);
}
private ResponseEntity<ApiResponse<Void>> failure(ResultCode code, String message,
HttpServletRequest request) {
ApiResponse<Void> body = ApiResponse.failure(code, message,
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
return ResponseEntity.status(code.httpStatus()).body(body);
recordFailure(code);
return ResponseEntity.ok(body);
}
private void recordFailure(ResultCode code) {
if (meterRegistry != null) {
meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment();
}
}
}
@@ -1 +1,2 @@
com.baiyue.common.web.LongIdJacksonConfiguration
com.baiyue.common.web.CommonWebAutoConfiguration
@@ -0,0 +1,31 @@
package com.baiyue.common.web;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class CommonWebAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(CommonWebAutoConfiguration.class));
@Test
void registersExceptionHandlerAndCorrelationFilterOutsideComponentScan() {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
assertThat(context).hasSingleBean(CorrelationIdFilter.class);
});
}
@Test
void backsOffWhenApplicationProvidesItsOwnBeans() {
contextRunner.withBean(GlobalExceptionHandler.class, GlobalExceptionHandler::new)
.withBean(CorrelationIdFilter.class, CorrelationIdFilter::new)
.run(context -> {
assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
assertThat(context).hasSingleBean(CorrelationIdFilter.class);
});
}
}
@@ -2,11 +2,13 @@ package com.baiyue.common.web;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method;
@@ -22,7 +24,7 @@ class GlobalExceptionHandlerTest {
request.setAttribute(CorrelationContext.REQUEST_ID, "r1");
request.setAttribute(CorrelationContext.TRACE_ID, "t1");
var response = handler.handleBusiness(new BizException("inventory is locked"), request);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BUSINESS_ERROR.code());
assertThat(response.getBody().requestId()).isEqualTo("r1");
}
@@ -37,10 +39,22 @@ class GlobalExceptionHandlerTest {
var response = handler.handleBadRequest(exception, request);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
}
@Test
void recordsBusinessFailureMetricByFiniteResultCode() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
ReflectionTestUtils.setField(handler, "meterRegistry", registry);
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "duplicate"),
new MockHttpServletRequest());
assertThat(registry.get("baiyue.api.business.failures")
.tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0);
}
private void paging(long page) {
}
}
@@ -0,0 +1,267 @@
# 统一响应约定改造方案:HTTP 200 + body code
> 状态:代码改造与后端全量测试已完成,待测试环境发布验证
> 日期:2026-08-01
## 1. 最终约定
业务接口统一返回 HTTP `200`,调用方只按响应体判断业务结果:
| 判断条件 | 含义 |
|---|---|
| HTTP `200` 且 body `code == "200"` | 成功 |
| HTTP `200` 且 body `code != "200"` | 业务失败,展示或记录 `message` |
| HTTP 非 `200` | 鉴权、协议、网关或基础设施异常,不属于普通业务失败 |
`code` 是字符串。前端不得再把旧成功码 `"0"` 视为成功。
```json
{
"code": "200",
"message": "success",
"data": {},
"requestId": "...",
"traceId": "...",
"timestamp": "2026-08-01T00:00:00Z"
}
```
业务失败示例:
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"code":"A0409","message":"Review already completed / 审核已完成","data":null}
```
## 2. 保留真实 HTTP 状态的边界
以下响应不强制改成 HTTP 200:
- 网关鉴权和 Servlet Security 入口的 `401/403`,前端 token 刷新依赖真实 `401`。
- Event Bridge 内部令牌保护的 `401/503`。
- Warehouse readiness 探针的 `503`。
- Audit 异步接收成功的 `202`。
- 框架、协议和基础设施错误,例如路由不存在 `404`、`405`、`413`、`415`、代理或网关 `502/504`。
控制器或业务服务抛出的 `BizException`、参数校验失败和统一异常处理器捕获的系统异常,均返回 HTTP 200 + 非 200 body code。
## 3. 后端实现
### 3.1 统一响应
- `ResultCode.SUCCESS` 已从 `"0"` 改为 `"200"`。
- `ApiResponse.successful()` 只认 `ResultCode.SUCCESS.code()`。
- `GlobalExceptionHandler` 的业务、校验和兜底异常统一使用 `ResponseEntity.ok(...)`。
- Warehouse 数据访问异常处理器同步使用 HTTP 200。
- `common-web` 增加自动配置,确保只扫描自身包的 audit-service、event-bridge-service 也能注册统一异常处理器和关联 ID 过滤器。
### 3.2 服务间调用
Feign 调用统一按新契约处理:
1. 提供方返回 HTTP 200:检查 `response.successful()`,失败时读取 body `code/message`。
2. 提供方返回 HTTP 非 200:视为鉴权、协议、网关或基础设施异常,统一映射为 `SERVICE_UNAVAILABLE`,不得按 HTTP 404/409 等状态推断业务结果。
3. 空响应、解码失败、契约漂移或传输异常同样统一映射为 `SERVICE_UNAVAILABLE`。
`ResultCode.of(rawCode)` 只还原本地已知枚举;未知或空 code 会降级为 `B0001`,不会原样透传未知 code,也不会误判成功。
服务间调用统一收敛为 `OrderEligibilityGuard` 的三段式写法:
```java
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "...服务暂时不可用");
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "...响应无效");
}
```
> `catch (BizException)` 必须显式置于 `RuntimeException` 之前,否则主路径抛出的业务异常会被兜底分支吞掉、降级成 `S0503`。
>
> Spring MVC 对**未匹配路由**返回的 404 不经过 `GlobalExceptionHandler`,仍是真 404;服务间调用方不得把这个协议错误伪装成业务资源不存在。
## 4. 企业审核代码与修复
当前已有企业审核流程:
- 接口:`PlatformTenantController` 的 `/reviews/{id}/approve` 与 `/reviews/{id}/reject`。
- 服务:`TenantService.review()`。
- 表:`iam_tenant_review`,审核意见字段为 `VARCHAR(500)`。
本次补齐:
- `ReviewRequest.comment` 增加 `@Size(max = 500)`(`TenantDtos.java:52`),审批和驳回请求增加 `@Valid`(`PlatformTenantController.java:122`、`:137`)。
- 审核状态使用 `WHERE id = ? AND status = 'PENDING'` 条件更新(`TenantRepository.completeReview()`)。
- 只有影响行数为 1 的请求获得审核权;重复或并发请求返回 body code `A0409`。
- 获得审核权后才更新租户和待审资质,整个流程仍在同一事务内;后续步骤失败时审核更新一并回滚。
这样可避免两个操作员同时通过/驳回时相互覆盖审核结果。
> 实现说明:`TenantService.review()` 中 `if (!"PENDING".equals(review.getStatus()))`(`:98`)只是读后快速失败,用于给出即时提示;真正的并发防护在 `completeReview()` 的条件更新 —— 两个并发请求都通过前置检查时,只有一个能拿到 `affected == 1`,另一个落到 `:105` 抛 `A0409`。两层都保留是合理的,不要因为「看起来重复」而删掉任一层。
## 5. 前端约定
admin-web 与 client-web 都只在 `code === "200"` 时解包数据;其他 body code 转为业务错误。真实 HTTP `401` 的刷新重试逻辑保持不变,真实 5xx 的防御性错误解析也继续保留。
### 5.1 改造落点(2026-08-01 实测确认已完成)
| 仓库 | 位置 | 现状 |
|---|---|---|
| admin-web | `src/api/http.ts:101` | `if (response?.code !== '200')` ✅ |
| client-web | `src/api/http.ts:17` | `if (envelope.code !== '200')` ✅ |
| admin-web | `src/stores/auth.test.ts:13-16` | 测试内自建 unwrap 同步改为 `!== '200'` ✅ |
| admin-web | 4 个 api/utils 测试文件 | mock 数据已全部改为 `code: '200'`,共 24 处 ✅ |
`code` 为字符串,前端使用严格比较 `!==`,与后端 `ApiResponse.code`(`String` 类型,Jackson 不做数字推断)对齐。若后端误改为数字类型,`'200' !== 200` 恒为真会导致全站失败 —— 见 3.1。
### 5.2 防回归断言
`admin-web/src/api/http.test.ts:21-26` 新增用例 `treats only body code 200 as success`,显式断言旧成功码 `'0'` 现在必须抛 `ApiResponseError`:
```ts
expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } })).toEqual({ id: '1' })
expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null }))
.toThrow(ApiResponseError)
```
这是文件里唯一保留的 `code: '0'`,属于有意为之,核查脚本扫到时不应误判为漏改。
client-web 通过 `src/views/AuthViews.test.mjs` 的源码契约测试覆盖 `unwrap()`:明确要求 `code !== '200'`,并拒绝旧的 `code !== '0'` 判断。
### 5.3 不受影响的应用
`apps/` 下另外 4 个应用经核查无成功码判断逻辑:
| 应用 | 情况 |
|---|---|
| `mobile-app`、`website`、`wechat-miniprogram` | 仅有 `AGENTS.md` / `README.md`,尚无实现代码 |
| `pda-uniapp` | 有页面代码但无 `ApiResponse` 判定(`.code` 均为任务编号或构建产物) |
另注:`admin-web/shared/src/index.ts:30` 的 `'0'` 是数字字符范围判断(`>= '0' && <= '9'`),与成功码无关,勿改。
## 6. OpenAPI 与可观测性
- 控制器注解以及 IAM、Order、Firstleg 静态 OpenAPI 已移除普通业务 `400/404/409/422/503` 响应声明。
实测确认:三份静态 OpenAPI 中此类声明为 0 处;控制器注解仅剩 `EventBridgeController.java:41-42` 的 `401/503`,属第 2 节允许的内部令牌保护边界,**不是漏改**。
- HTTP 200 的描述明确要求继续检查 body code;真实鉴权状态继续保留。
- 统一异常处理器增加 `baiyue.api.business.failures` 计数器,仅使用有限的结果码作为 `code` 标签,避免高基数。
- Prometheus 中对应的计数指标通常导出为 `baiyue_api_business_failures_total{code="A0409"}`。
发布后,业务错误率告警应基于该指标,不再依赖 HTTP 4xx/5xx 曲线。
## 7. 发布顺序
1. 同批发布 `common-core`、`common-web` 和所有包含它们的服务构建产物。
2. 同批发布已改造的 Feign 调用方,避免新提供方与旧调用方形成契约窗口。
3. 发布 admin-web 与 client-web;前后端不可长时间跨版本运行,因为旧前端不识别成功码 `"200"`,新前端也不再接受 `"0"`。
4. 验证成功、业务失败、真实 401、readiness 503、audit 202。
5. 确认业务失败指标和告警正常后再完成发布。
不建议在后端成功码切换和前端切换之间做长时间灰度。如必须灰度,应由网关或独立兼容层按客户端版本转换成功码,不能让客户端同时猜测 `"0"` 和 `"200"`。
## 8. 核查与验收
### 8.1 静态核查脚本
```bash
# —— 后端(在 baiyue-firstleg 下执行)——
# 成功码应为字符串 "200"
grep -n 'SUCCESS(' common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java
# 期望:SUCCESS("200", "success", HttpStatus.OK)
# code 字段必须是 String,不能是 int/Integer
grep -n 'code' common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java | head -3
# 期望:String code,
# 除枚举定义外不应再有 "0" 成功码硬编码
grep -rn '"0"' --include="*.java" */src/main | grep -v 'ResultCode.java' | grep -v 'logic-delete'
# 生产代码不应使用 Feign HTTP 状态码推断业务结果;此命令应无输出
grep -rn 'exception\.status()\|feign\.status()\|FeignException\.NotFound' --include="*.java" */src/main
# —— 前端(在 apps 下执行)——
# 两个前端的成功码判断
grep -rn "code !== '200'" admin-web/src client-web/src --include="*.ts" --exclude-dir=node_modules
# 期望:admin-web/src/api/http.ts、client-web/src/api/http.ts 各一处
# 残留旧成功码:期望仅 http.test.ts 那一条反向断言(见 5.2)
grep -rn "code: '0'\|code !== '0'\|code === '0'" admin-web/src client-web/src \
--include="*.ts" --include="*.tsx" --exclude-dir=node_modules
```
### 8.2 构建与测试
```bash
# 后端:common-web 是共享依赖,必须带 -am
cd baiyue-firstleg
mvn -pl common-core,common-web,auth,iam,file,order,warehouse,audit-service,event-bridge-service -am test
# 前端
cd ../apps/admin-web && pnpm test && pnpm typecheck && pnpm build
cd ../client-web && pnpm test && pnpm typecheck && pnpm build
```
### 8.3 部署后冒烟
```bash
# ① 业务成功:HTTP 200 + "code":"200"(不再是 "0")
curl -isS "http://8.163.30.128:9000/api/merchant/v1/iam/tenant/qualifications" \
-H "Authorization: Bearer <MERCHANT_TOKEN>" | sed -n '1p;/^{/p'
# ② 业务失败:HTTP 200 + "code":"A0409"
curl -isS -X POST "http://8.163.30.128:9000/api/merchant/v1/files/qualifications" \
-H "Authorization: Bearer <MERCHANT_TOKEN>" \
-F "file=@/path/to/license.png" -F "certificateNo=91310000PHASE10001" \
| sed -n '1p;/^{/p'
# ③ 真实 401 保持(前端 token 刷新依赖)
curl -isS -X POST "http://8.163.30.128:9000/api/merchant/v1/files/qualifications" | sed -n '1p'
# ④ readiness 真实 503
curl -isS "http://8.163.30.128:9205/actuator/health/readiness" | sed -n '1p'
# ⑤ 业务失败指标
curl -sS "http://8.163.30.128:9201/actuator/prometheus" | grep baiyue_api_business_failures_total
```
> ① 是本次成功码改造的关键验证点:若仍返回 `"code":"0"` 说明该服务未重新构建(`common-core` 打包在各服务 jar 内)。
### 8.4 回滚
无数据库变更、无 Nacos 变更,回滚即回退代码重新构建。注意**前后端必须一起回滚** —— 只回滚一侧会立即造成全站失败,原因见第 7 节。
## 9. 验收清单
**静态核查(2026-08-01 已实测)**
- [x] 后端成功码为字符串 `"200"` — `ResultCode.java:11`,字段类型 `String`
- [x] `ApiResponse.code` 为 `String`,Jackson 不做数字推断,序列化为 `"200"`
- [x] admin-web `src/api/http.ts:101` 判 `!== '200'`
- [x] client-web `src/api/http.ts:17` 判 `!== '200'`
- [x] 前端 mock 与自建 unwrap 全部改为 `'200'`(24 处)
- [x] 唯一残留的 `code: '0'` 是 `http.test.ts:24` 的防回归反向断言,属有意保留
**运行时验收(部署后按 8.3 执行)**
- [ ] 成功响应:HTTP 200 + `code: "200"`。
- [ ] 参数错误、资源不存在、冲突、下游业务失败:HTTP 200 + 非 200 code。
- [ ] 重复或并发审核:仅一个请求成功,其余返回 `A0409`。
- [ ] 超过 500 字的审核意见:HTTP 200 + `A0422`。
- [ ] 未登录请求:真实 HTTP 401,前端刷新逻辑正常。
- [ ] Warehouse readiness 不可用:真实 HTTP 503。
- [ ] Audit 接收成功:真实 HTTP 202,body `code: "200"`。
- [ ] Prometheus 能查询 `baiyue_api_business_failures_total`。
- [ ] 三个仓库测试、类型检查和构建全部通过(8.2)。
**遗留项**
- [x] 已删除 Feign 旧 HTTP 状态码业务映射;服务间调用统一使用 HTTP 200 body code
## 10. 与本改造无关的现有工作区改动
`file` 模块的 `LocalQualificationStorage` 及其测试是已有的存储目录写入探针改动,不属于响应约定。本次不覆盖、不回退,提交时建议与响应改造拆分。
@@ -6,8 +6,6 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,7 +24,6 @@ public class QualificationFileController {
private static final Logger LOGGER = LoggerFactory.getLogger(QualificationFileController.class);
@Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -41,7 +38,16 @@ public class QualificationFileController {
var response = iamClient.submit(internalToken,
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
certificateNo, stored.documentUrl()));
if (response == null || !response.successful() || response.data() == null) {
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed");
}
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, "Qualification workflow failed");
}
var data = response.data();
@@ -50,35 +56,11 @@ public class QualificationFileController {
} catch (RuntimeException exception) {
storage.delete(stored);
if (exception instanceof BizException business) throw business;
if (exception instanceof FeignException feign) {
LOGGER.warn("IAM 资质登记被拒绝:status={} body={}", feign.status(), feign.contentUTF8());
if (feign.status() >= 400 && feign.status() < 500) {
throw new BizException(resultCodeOf(feign.status()), iamMessage(feign));
}
}
LOGGER.error("IAM 资质登记调用失败", exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
}
}
/** 透出 IAM 返回的业务错误消息,避免把"已审核通过""重复提交"等业务拒绝误报为服务不可用。 */
private String iamMessage(FeignException feign) {
try {
String message = objectMapper.readTree(feign.contentUTF8()).path("message").asText(null);
if (message != null && !message.isBlank()) return message;
} catch (Exception ignored) { }
return "Enterprise qualification submission was rejected";
}
private ResultCode resultCodeOf(int status) {
return switch (status) {
case 403 -> ResultCode.FORBIDDEN;
case 404 -> ResultCode.NOT_FOUND;
case 409 -> ResultCode.CONFLICT;
default -> ResultCode.BUSINESS_ERROR;
};
}
@GetMapping("/{fileName}")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) {
@@ -54,10 +54,7 @@ public class PlatformFulfillmentAssignmentController {
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Assignment succeeded / 指派成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid tenant or request / 租户或请求参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Task not found in target tenant / 目标租户下任务不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Task is closed or concurrently updated / 任务已关闭或发生并发更新")
})
@PostMapping("/{taskId}/assignment")
@PreAuthorize("hasAuthority('platform:firstleg:assignment:assign')")
@@ -101,7 +98,6 @@ public class PlatformFulfillmentAssignmentController {
@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 = "任务不存在")
})
@GetMapping("/{taskId}")
@PreAuthorize("hasAuthority('platform:firstleg:task:view')")
@@ -118,7 +114,6 @@ public class PlatformFulfillmentAssignmentController {
@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 = "任务不存在")
})
@GetMapping("/by-order/{orderId}")
@PreAuthorize("hasAuthority('platform:firstleg:task:view')")
@@ -135,7 +130,6 @@ public class PlatformFulfillmentAssignmentController {
@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 = "任务不存在")
})
@GetMapping("/{taskId}/assignment-history")
@PreAuthorize("hasAuthority('platform:firstleg:assignment:history')")
@@ -1,6 +1,7 @@
openapi: 3.0.3
info:
title: 柏悦头程履约 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.1.0
security:
- bearerAuth: []
@@ -17,7 +18,7 @@ paths:
- {name: status, in: query, schema: {type: string, enum: [PENDING_ASSIGNMENT, ASSIGNED, PROCESSING, COMPLETED, CANCELLED]}}
- {name: assigneeId, in: query, schema: {type: integer, format: int64}}
responses:
'200': {description: 查询成功}
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无查询权限}
/api/platform/v1/first-leg/tasks/{taskId}:
get:
@@ -27,9 +28,8 @@ paths:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/TaskId'
responses:
'200': {description: 查询成功}
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无查看权限}
'404': {description: 任务不存在}
/api/platform/v1/first-leg/tasks/by-order/{orderId}:
get:
summary: 按订单查询履约任务
@@ -38,9 +38,8 @@ paths:
- $ref: '#/components/parameters/TenantId'
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
responses:
'200': {description: 查询成功}
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无查看权限}
'404': {description: 任务不存在}
/api/platform/v1/first-leg/tasks/{taskId}/assignment-history:
get:
summary: 分页查询任务分配历史
@@ -51,9 +50,8 @@ paths:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/Size'
responses:
'200': {description: 查询成功}
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无历史查看权限}
'404': {description: 任务不存在}
/api/platform/v1/first-leg/tasks/{taskId}/assignment:
post:
summary: 分配或改派履约任务
@@ -67,10 +65,8 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/AssignRequest'}
responses:
'200': {description: 分配成功}
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无分配权限}
'404': {description: 任务不存在}
'409': {description: 任务已关闭或发生并发更新}
components:
securitySchemes:
bearerAuth:
@@ -49,7 +49,10 @@ class FulfillmentTaskControllerContractTest {
.contains("/api/platform/v1/first-leg/tasks/by-order/{orderId}:")
.contains("/api/platform/v1/first-leg/tasks/{taskId}/assignment-history:")
.contains("platform:firstleg:task:list")
.contains("platform:firstleg:assignment:history");
.contains("platform:firstleg:assignment:history")
.contains("仅响应体 code 为字符串 \"200\" 时表示成功")
.doesNotContain("'404':")
.doesNotContain("'409':");
}
private void assertAuthority(String methodName, String permission, Class<?>... parameterTypes)
@@ -56,7 +56,6 @@ public class MerchantTenantController {
@GetMapping("/tenant/profile")
@PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")})
public ApiResponse<TenantResponse> profile(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.profile(TenantGuard.tenant(user, "MERCHANT")));
}
@@ -64,7 +63,6 @@ public class MerchantTenantController {
@PutMapping("/tenant/profile")
@PreAuthorize("hasAuthority('merchant:profile:edit')")
@Operation(summary = "Update current enterprise profile / 更新当前企业资料", description = "Update basic profile fields within the authenticated tenant. / 在当前认证租户内更新企业基础资料。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")})
public ApiResponse<TenantResponse> update(@Valid @RequestBody UpdateProfileRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
@@ -88,7 +86,6 @@ public class MerchantTenantController {
@PostMapping("/members")
@Operation(summary = "Add enterprise member / 新增企业成员", description = "Create a membership under the current JWT tenant. / 在当前 JWT 租户下创建成员关系。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Member already exists / 成员已存在")})
@PreAuthorize("hasAuthority('merchant:member:create')")
public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
@@ -43,10 +43,8 @@ public class PlatformTenantController {
@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 = "401", description = "需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "422", description = "分页范围或状态无效")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限")
})
public ApiResponse<PageResult<TenantResponse>> page(
@RequestParam(defaultValue = "1") @Min(1) long page,
@@ -73,9 +71,7 @@ public class PlatformTenantController {
@Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Enterprise already exists / 企业已存在")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")
})
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
@@ -120,11 +116,10 @@ public class PlatformTenantController {
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
})
public ApiResponse<Void> approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
@RequestBody ReviewRequest request,
@Valid @RequestBody ReviewRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.review(id, user.userId(), true, request.comment());
@@ -136,11 +131,10 @@ public class PlatformTenantController {
@Operation(summary = "Reject enterprise registration / 驳回企业注册", description = "Reject a pending review and retain the rejection comment. The operation is audited. / 驳回待审核企业并保留审核意见,操作写入审计日志。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Rejected / 已驳回"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
})
public ApiResponse<Void> reject(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
@RequestBody ReviewRequest request,
@Valid @RequestBody ReviewRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.review(id, user.userId(), false, request.comment());
@@ -43,7 +43,6 @@ public class ProfileResourceController {
@PostMapping("/api/platform/v1/iam/internal-users")
@PreAuthorize("hasAuthority('platform:user:create')")
@Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")})
public ApiResponse<InternalUserResponse> addUser(@Valid @RequestBody CreateInternalUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
@@ -101,7 +100,6 @@ public class ProfileResourceController {
@PostMapping("/api/merchant/v1/iam/tenant/addresses")
@PreAuthorize("hasAuthority('merchant:profile:edit')")
@Operation(summary = "Add enterprise address / 新增企业地址", description = "Add an operating, registered or return address to the current tenant. / 为当前租户新增经营、注册地址或退货地址。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid address / 地址不合法")})
public ApiResponse<IamTenantAddress> addAddress(@Valid @RequestBody AddressRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.addAddress(TenantGuard.tenant(user, "MERCHANT"), request));
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* 租户生命周期数据契约集合,定义入驻、资料更新、审核请求和租户响应。
@@ -47,7 +48,8 @@ public final class TenantDtos {
@Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见")
public record ReviewRequest(
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过")
@Size(max = 500, message = "审核意见不能超过 500 个字符") String comment
) { }
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
@@ -6,10 +6,12 @@ import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.mapper.IamTenantMapper;
import com.baiyue.iam.mapper.IamTenantReviewMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -41,7 +43,22 @@ public class TenantRepository {
public void insertTenant(IamTenant tenant) { tenantMapper.insert(tenant); }
public void saveTenant(IamTenant tenant) { tenantMapper.updateById(tenant); }
public void insertReview(IamTenantReview review) { reviewMapper.insert(review); }
public void saveReview(IamTenantReview review) { reviewMapper.updateById(review); }
/**
* Atomically completes a pending review. The status predicate makes duplicate or concurrent
* approval requests fail instead of overwriting the first review decision.
*/
public boolean completeReview(Long reviewId, String status, Long reviewerId, String comment,
LocalDateTime reviewedAt) {
int affected = reviewMapper.update(null, new LambdaUpdateWrapper<IamTenantReview>()
.eq(IamTenantReview::getId, reviewId)
.eq(IamTenantReview::getStatus, "PENDING")
.set(IamTenantReview::getStatus, status)
.set(IamTenantReview::getReviewerId, reviewerId)
.set(IamTenantReview::getReviewComment, comment)
.set(IamTenantReview::getReviewedAt, reviewedAt));
return affected == 1;
}
public List<IamTenantReview> findPendingReviews() {
return reviewMapper.selectList(new LambdaQueryWrapper<IamTenantReview>()
@@ -200,11 +200,19 @@ public class MemberService {
private <T> ApiResponse<T> call(RemoteCall<T> action) {
try {
ApiResponse<T> result = action.call();
if (result == null || !result.successful()) throw unavailable();
if (result == null) throw unavailable();
if (!result.successful()) {
ResultCode code = ResultCode.of(result.code());
String message = result.message() == null || result.message().isBlank()
? code.message() : result.message();
throw new BizException(code, message);
}
return result;
} catch (FeignException exception) {
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在");
throw unavailable();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
@@ -321,11 +321,19 @@ public class PlatformInternalUserService {
private <T> ApiResponse<T> call(RemoteCall<T> action) {
try {
ApiResponse<T> result = action.call();
if (result == null || !result.successful()) throw unavailable();
if (result == null) throw unavailable();
if (!result.successful()) {
ResultCode code = ResultCode.of(result.code());
String message = result.message() == null || result.message().isBlank()
? code.message() : result.message();
throw new BizException(code, message);
}
return result;
} catch (FeignException exception) {
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在");
throw unavailable();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
@@ -99,11 +99,11 @@ public class TenantService {
throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成");
}
review.setStatus(approve ? "APPROVED" : "REJECTED");
review.setReviewerId(reviewerId);
review.setReviewComment(comment);
review.setReviewedAt(LocalDateTime.now());
repository.saveReview(review);
LocalDateTime reviewedAt = LocalDateTime.now();
if (!repository.completeReview(reviewId, approve ? "APPROVED" : "REJECTED",
reviewerId, comment, reviewedAt)) {
throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成");
}
IamTenant tenant = repository.findTenant(review.getTenantId());
if (tenant == null) {
+3 -49
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Baiyue IAM API / 百悦身份与权限 API
description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。鉴权和协议层异常仍使用真实 HTTP 状态码。Tenant, member, role, menu, page, button and API permission management.
version: 1.0.1
servers:
- url: /
@@ -33,10 +33,8 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/TenantPageApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'422': {$ref: '#/components/responses/UnprocessableEntity'}
post:
tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业租户
@@ -48,9 +46,7 @@ paths:
schema: {$ref: '#/components/schemas/RegisterRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
@@ -73,7 +69,6 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
put:
tags: [Platform Tenant / 平台租户]
summary: 编辑企业客户档案
@@ -84,9 +79,7 @@ paths:
schema: {$ref: '#/components/schemas/UpdateTenantRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/tenants/{tenantId}/status:
parameters:
- {$ref: '#/components/parameters/TenantId'}
@@ -100,10 +93,7 @@ paths:
schema: {$ref: '#/components/schemas/ChangeTenantStatusRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
@@ -140,8 +130,6 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/reviews/{id}/reject:
post:
tags: [Platform Tenant / 平台租户]
@@ -157,8 +145,6 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/internal-users:
get:
tags: [IAM Resources / IAM 资源]
@@ -181,9 +167,7 @@ paths:
schema: {$ref: '#/components/schemas/CreateInternalUserRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/internal-users/{membershipId}:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
@@ -197,10 +181,7 @@ paths:
schema: {$ref: '#/components/schemas/UpdateInternalUserRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/internal-users/{membershipId}/status:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
@@ -214,9 +195,7 @@ paths:
schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/internal-users/{membershipId}/password:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
@@ -230,9 +209,7 @@ paths:
schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/internal-users/{membershipId}/roles:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
@@ -251,9 +228,7 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/InternalUserApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/merchant/v1/iam/tenant/profile:
get:
tags: [Merchant Tenant / 商户企业]
@@ -263,7 +238,6 @@ paths:
'200': {$ref: '#/components/responses/Success'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
put:
tags: [Merchant Tenant / 商户企业]
summary: Update current tenant profile / 更新当前企业资料
@@ -274,9 +248,7 @@ paths:
schema: {$ref: '#/components/schemas/UpdateProfileRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/merchant/v1/iam/members:
get:
tags: [Merchant Tenant / 商户企业]
@@ -295,9 +267,7 @@ paths:
schema: {$ref: '#/components/schemas/CreateMemberRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/tenant/addresses:
get:
tags: [IAM Resources / IAM 资源]
@@ -315,7 +285,6 @@ paths:
schema: {$ref: '#/components/schemas/AddressRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/tenant/qualifications:
get:
@@ -344,9 +313,7 @@ paths:
schema: {$ref: '#/components/schemas/CreateRoleRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/members/roles:
put:
tags: [RBAC]
@@ -361,7 +328,6 @@ paths:
schema: {$ref: '#/components/schemas/AssignRolesRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/{app}/v1/iam/permissions:
parameters:
@@ -383,9 +349,7 @@ paths:
schema: {$ref: '#/components/schemas/CreatePermissionRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/roles/permissions:
put:
tags: [RBAC]
@@ -400,7 +364,6 @@ paths:
schema: {$ref: '#/components/schemas/GrantPermissionsRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/{app}/v1/iam/permissions/{permissionId}:
parameters:
@@ -417,9 +380,7 @@ paths:
schema: {$ref: '#/components/schemas/UpdatePermissionRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
delete:
tags: [RBAC]
summary: 删除未绑定的自定义权限资源
@@ -427,7 +388,6 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/permissions/{permissionId}/status:
parameters:
- {$ref: '#/components/parameters/App'}
@@ -442,9 +402,7 @@ paths:
schema: {$ref: '#/components/schemas/ChangePermissionStatusRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
components:
securitySchemes:
bearerAuth:
@@ -486,18 +444,14 @@ components:
schema: {type: integer, format: int64}
responses:
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas:
ApiResponse:
type: object
required: [code, message, timestamp]
properties:
code: {type: string, example: '00000'}
code: {type: string, example: '200', description: 200 表示成功,其他值表示业务失败}
message: {type: string, example: Success}
data: {}
requestId: {type: string}
@@ -587,7 +541,7 @@ components:
ReviewRequest:
type: object
properties:
comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因}
comment: {type: string, maxLength: 500, description: Review comment or rejection reason / 审核意见或驳回原因}
CreateInternalUserRequest:
type: object
required: [displayName]
@@ -29,7 +29,8 @@ class IamOpenApiContractTest {
.contains("AssignInternalUserRolesRequest:")
.contains("membershipId: {type: integer, format: int64}")
.contains("roleIds: {type: array, items: {type: integer, format: int64}}")
.contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
.contains("example: '200'")
.doesNotContain("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
Map<String, Object> openApi = new Yaml().load(document);
assertThat(openApi).containsEntry("openapi", "3.0.3");
@@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -22,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
@DisplayName("平台客户接口契约")
class PlatformTenantControllerContractTest {
@Test
void merchantPageEndpointDeclaresPermissionPaginationConstraintsAndErrorStatuses() throws Exception {
void merchantPageEndpointDeclaresPermissionPaginationConstraintsAndUnifiedBusinessStatus() throws Exception {
Method method = PlatformTenantController.class.getMethod("page", long.class, long.class,
String.class, String.class, AuthenticatedUser.class);
@@ -38,7 +40,7 @@ class PlatformTenantControllerContractTest {
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
.map(io.swagger.v3.oas.annotations.responses.ApiResponse::responseCode)
.collect(Collectors.toSet());
assertEquals(Set.of("200", "400", "401", "403", "422"), statuses);
assertEquals(Set.of("200", "401", "403"), statuses);
}
@Test
@@ -49,6 +51,19 @@ class PlatformTenantControllerContractTest {
method.getAnnotation(PreAuthorize.class).value());
}
@Test
void reviewRequestsAreValidatedAndCommentMatchesDatabaseLimit() throws Exception {
Method approve = PlatformTenantController.class.getMethod("approve", long.class,
com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class);
Method reject = PlatformTenantController.class.getMethod("reject", long.class,
com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class);
Method comment = com.baiyue.iam.dto.TenantDtos.ReviewRequest.class.getMethod("comment");
assertNotNull(annotation(approve, 1, Valid.class));
assertNotNull(annotation(reject, 1, Valid.class));
assertEquals(500, comment.getAnnotation(Size.class).max());
}
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
return method.getParameters()[parameter].getAnnotation(type);
}
@@ -72,8 +72,22 @@ class MemberServiceTest {
@Test void authServiceFailureDoesNotPersistLocalStatus() {
when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败"));
assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L,
BizException exception = assertThrows(BizException.class,
() -> service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 99L));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(repository, never()).save(member);
}
@Test void authHttp200BusinessFailureKeepsBusinessCodeAndDoesNotPersistLocalStatus() {
when(auth.status(anyString(), eq(40L), any())).thenReturn(
ApiResponse.<Void>failure(ResultCode.NOT_FOUND, "认证账号不存在", null, null));
BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 99L));
assertEquals(ResultCode.NOT_FOUND, exception.getResultCode());
assertEquals("认证账号不存在", exception.getMessage());
verify(repository, never()).save(member);
}
@@ -1,6 +1,7 @@
package com.baiyue.iam.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.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
@@ -58,6 +59,29 @@ class PlatformInternalUserServiceTest {
assertEquals(List.of(40L), result.roleIds());
}
@Test
void authHttp200BusinessFailureKeepsBusinessCode() {
when(repository.findByTenant(0L)).thenReturn(List.of(membership));
when(auth.summary("internal-token", 10L)).thenReturn(
ApiResponse.<AuthAccountClient.AccountSummary>failure(
ResultCode.NOT_FOUND, "认证账号不存在", null, null));
BizException exception = assertThrows(BizException.class, service::list);
assertEquals(ResultCode.NOT_FOUND, exception.getResultCode());
assertEquals("认证账号不存在", exception.getMessage());
}
@Test
void authDecodeOrTransportFailureMapsToServiceUnavailable() {
when(repository.findByTenant(0L)).thenReturn(List.of(membership));
when(auth.summary("internal-token", 10L)).thenThrow(new RuntimeException("decode failed"));
BizException exception = assertThrows(BizException.class, service::list);
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
}
@Test
void createPlatformUserCreatesPlatformAccountInAuthAndMembershipInIam() {
IamRole role = new IamRole();
@@ -2,6 +2,7 @@ package com.baiyue.iam.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.repository.TenantRepository;
@@ -15,6 +16,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.baiyue.common.core.exception.BizException;
@@ -98,6 +100,59 @@ class TenantServiceTest {
assertEquals(true, withQualification.eligible());
}
@Test
void reviewCompletesPendingTicketWithConditionalUpdate() {
TenantRepository repository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
TenantService service = new TenantService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
IamTenantReview review = pendingReview(90001L, 10001L);
IamTenant tenant = merchant(10001L, "PENDING");
when(repository.findReview(90001L)).thenReturn(review);
when(repository.completeReview(org.mockito.ArgumentMatchers.eq(90001L),
org.mockito.ArgumentMatchers.eq("APPROVED"), org.mockito.ArgumentMatchers.eq(70001L),
org.mockito.ArgumentMatchers.eq("ok"), org.mockito.ArgumentMatchers.any()))
.thenReturn(true);
when(repository.findTenant(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of());
service.review(90001L, 70001L, true, "ok");
assertEquals("ACTIVE", tenant.getStatus());
assertEquals(70001L, tenant.getApprovedBy());
verify(repository).saveTenant(tenant);
}
@Test
void duplicateOrConcurrentReviewIsRejectedBeforeTenantMutation() {
TenantRepository repository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
TenantService service = new TenantService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
when(repository.findReview(90001L)).thenReturn(pendingReview(90001L, 10001L));
when(repository.completeReview(org.mockito.ArgumentMatchers.eq(90001L),
org.mockito.ArgumentMatchers.eq("REJECTED"), org.mockito.ArgumentMatchers.eq(70001L),
org.mockito.ArgumentMatchers.eq("no"), org.mockito.ArgumentMatchers.any()))
.thenReturn(false);
BizException exception = assertThrows(BizException.class,
() -> service.review(90001L, 70001L, false, "no"));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).findTenant(org.mockito.ArgumentMatchers.anyLong());
verify(repository, never()).saveTenant(org.mockito.ArgumentMatchers.any());
}
private IamTenantReview pendingReview(long id, long tenantId) {
IamTenantReview review = new IamTenantReview();
review.setId(id);
review.setTenantId(tenantId);
review.setStatus("PENDING");
return review;
}
private IamTenant merchant(long id, String status) {
IamTenant tenant = new IamTenant();
tenant.setId(id);
@@ -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() {
@@ -38,7 +38,6 @@ public class MerchantWarehouseController {
)
@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 = "401", description = "Authentication required / 需要登录")
})
@GetMapping
@@ -55,7 +54,6 @@ public class MerchantWarehouseController {
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found in current tenant / 当前租户下仓库不存在")
})
@GetMapping("/{id}")
public ApiResponse<WarehouseDetailResponse> detail(
@@ -46,7 +46,6 @@ public class PlatformExpressCompanyController {
@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 = "401", description = "需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台快递公司查询权限")
})
@@ -46,7 +46,6 @@ public class PlatformPartnerController {
@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 = "401", description = "需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台往来单位查询权限")
})
@@ -48,9 +48,7 @@ public class PlatformWarehouseController {
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
})
@PostMapping
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -66,10 +64,7 @@ public class PlatformWarehouseController {
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
})
@PutMapping("/{id}")
public ApiResponse<Void> update(
@@ -91,7 +86,6 @@ public class PlatformWarehouseController {
)
@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 = "Platform access required / 需要平台端权限")
})
@GetMapping
@@ -112,7 +106,6 @@ public class PlatformWarehouseController {
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
})
@GetMapping("/{id}")
public ApiResponse<WarehouseDetailResponse> detail(
@@ -132,9 +125,7 @@ public class PlatformWarehouseController {
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
})
@PatchMapping("/{id}/status")
public ApiResponse<Void> status(
@@ -13,7 +13,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 仓储数据库不可用或结构漂移时返回可识别的 503,并保留完整链路日志。 */
/** 仓储数据库不可用或结构漂移时返回可识别的业务码,并保留完整链路日志。 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.baiyue.warehouse.controller")
public class WarehouseDataAccessExceptionHandler {
@@ -25,7 +25,7 @@ public class WarehouseDataAccessExceptionHandler {
String traceId = CorrelationContext.traceId(request);
log.error("Warehouse data access failure requestId={} traceId={}", requestId, traceId, exception);
ResultCode code = ResultCode.SERVICE_UNAVAILABLE;
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.failure(code,
return ResponseEntity.ok(ApiResponse.failure(code,
"仓储数据库暂不可用,请检查数据源和数据库迁移", requestId, traceId));
}
}
@@ -13,7 +13,7 @@ import static org.mockito.Mockito.when;
@DisplayName("仓库数据访问异常处理器")
class WarehouseDataAccessExceptionHandlerTest {
@Test
@DisplayName("数据源故障统一返回503并保留请求标识")
@DisplayName("数据源故障统一返回HTTP 200与S0503业务码并保留请求标识")
void shouldReturnServiceUnavailableAndPreserveRequestIdsForDataSourceFailure() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getAttribute("requestId")).thenReturn("request-1");
@@ -22,7 +22,7 @@ class WarehouseDataAccessExceptionHandlerTest {
var response = new WarehouseDataAccessExceptionHandler().handle(
new DataAccessResourceFailureException("database unavailable"), request);
assertThat(response.getStatusCode().value()).isEqualTo(503);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE.code());
assertThat(response.getBody().message()).contains("数据库");