diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java index ff361f6ec..2ecd0dd5c 100644 --- a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java +++ b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java @@ -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) diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java index a48bc5303..df0266849 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java @@ -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 权限服务暂时不可用"); } } diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 2fd921852..ecee6b279 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -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 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 refresh(@Valid @RequestBody RefreshRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 1b9ff3769..64287017c 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -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"); } diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java index edda90feb..7e58bd886 100644 --- a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java +++ b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java @@ -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); + } } diff --git a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java index b4dbca1a2..ca94dff69 100644 --- a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java +++ b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java @@ -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 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 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 result = new PageResult<>(List.of("a"), 1, 20, 1, 1); diff --git a/common-web/pom.xml b/common-web/pom.xml index a9e635d84..4fdc1b567 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -31,6 +31,10 @@ org.springframework.boot spring-boot-starter-validation + + io.micrometer + micrometer-core + org.springframework.boot spring-boot-starter-test diff --git a/common-web/src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java new file mode 100644 index 000000000..afa1a811e --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java @@ -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(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 357426898..8d238f621 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -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> 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> 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> failure(ResultCode code, String message, HttpServletRequest request) { ApiResponse 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(); + } } } - diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 013f360a4..66426422f 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ com.baiyue.common.web.LongIdJacksonConfiguration +com.baiyue.common.web.CommonWebAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java new file mode 100644 index 000000000..bab2952df --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java @@ -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); + }); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index 98f7ec0c6..d2c6c808b 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -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) { } } diff --git a/docs/统一响应约定改造方案-HTTP200.md b/docs/统一响应约定改造方案-HTTP200.md new file mode 100644 index 000000000..1a9b51e70 --- /dev/null +++ b/docs/统一响应约定改造方案-HTTP200.md @@ -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 " | 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 " \ + -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` 及其测试是已有的存储目录写入探针改动,不属于响应约定。本次不覆盖、不回退,提交时建议与响应改造拆分。 diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index 31e999de1..f383ebf55 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -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 download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java index 7f8289f2b..2aa01fa7a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java @@ -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')") diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 5bef8f64f..5710c91a9 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java index 66645eefb..4f93e646d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java @@ -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) diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java index 84491bb47..23a4ce1da 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java @@ -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 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 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 addMember(@Valid @RequestBody CreateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) { diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 1ad414d52..a237641cb 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -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> 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 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 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 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()); diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index d4d8f77c3..35cb047b7 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -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 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 addAddress(@Valid @RequestBody AddressRequest request, @AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.addAddress(TenantGuard.tenant(user, "MERCHANT"), request)); diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index fa7ef44a8..5bde9a48d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -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 / 企业租户概要") diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index 7b54b5a45..a58629118 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -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() + .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 findPendingReviews() { return reviewMapper.selectList(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 80d582e35..0d6f99055 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -200,11 +200,19 @@ public class MemberService { private ApiResponse call(RemoteCall action) { try { ApiResponse 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(); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 4ecc46595..8a8e30655 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -321,11 +321,19 @@ public class PlatformInternalUserService { private ApiResponse call(RemoteCall action) { try { ApiResponse 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(); } } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 611ab1687..962c89dd4 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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) { diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index d12ba99f6..421e959bb 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -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] diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index c4b06bf0b..bd6559e87 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -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 openApi = new Yaml().load(document); assertThat(openApi).containsEntry("openapi", "3.0.3"); diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 2b9edd4d1..c58ca1d27 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -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 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 annotation(Method method, int parameter, Class type) { return method.getParameters()[parameter].getAnnotation(type); } diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 74bc6ae9e..827a529e8 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -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.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); } diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index fd91f9bb7..20126ea9f 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -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.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(); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index d2995bfaa..313c5dfed 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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); diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index 4eb80c612..b3d3ea93e 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -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')") diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 020202d32..cf1a4836e 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -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')") diff --git a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java index 15a138d54..5673a42d5 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java @@ -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()) { diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 19f99837a..506b76c3c 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -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) { } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f73723fef..f051cfe4a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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} diff --git a/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java index 855787fe6..cc346b148 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java @@ -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.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); diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 36349aeb8..35e841a9c 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -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 diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index aa51d2c82..a73464f49 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -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.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() { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index 598fa665c..f70a87f2d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -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 detail( diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java index 0b19ff27f..86fa128e4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -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 = "需要平台快递公司查询权限") }) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java index 48dfa3acb..91fcf0a37 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -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 = "需要平台往来单位查询权限") }) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 5f98848b3..67787d990 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -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 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 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 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 status( diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java index d0f7e9680..298baebda 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java @@ -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)); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java index e0800ad0e..e113b36a2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java @@ -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("数据库");