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