This commit is contained in:
2026-08-08 10:31:44 +08:00
parent ced35f1600
commit 2644718b5f
32 changed files with 9710 additions and 31 deletions
@@ -21,6 +21,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -2,6 +2,7 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -20,6 +21,8 @@ import java.security.MessageDigest;
public class InternalAccountController { public class InternalAccountController {
@Resource @Resource
private AccountManagementService service; private AccountManagementService service;
@Resource
private AuthService authService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String configuredToken; private String configuredToken;
@@ -76,7 +79,7 @@ public class InternalAccountController {
public ApiResponse<ImpersonationTicketResponse> impersonationTicket( public ApiResponse<ImpersonationTicketResponse> impersonationTicket(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody ImpersonationTicketRequest request) { @Valid @RequestBody ImpersonationTicketRequest request) {
verify(token); return ApiResponse.success(service.createImpersonationTicket(request)); verify(token); return ApiResponse.success(authService.createImpersonationTicket(request));
} }
@PutMapping("/tenant/{tenantId}/archive") @PutMapping("/tenant/{tenantId}/archive")
@@ -7,6 +7,8 @@ import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.auth.dto.LoginRequest; import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.dto.TokenResponse; import com.baiyue.auth.dto.TokenResponse;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketResponse;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set; import java.util.Set;
import java.util.List;
@DisplayName("账号管理服务") @DisplayName("账号管理服务")
@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class}) @SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class})
@@ -170,6 +171,42 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L); verify(sessions).revokeAll(10L);
verify(sessions).revokeAll(11L); verify(sessions).revokeAll(11L);
} }
@Test void archiveTenantDisablesEveryMerchantAccountAndRevokesSessions() {
AuthAccount owner = merchantAccount(20L, 10001L, "ACTIVE", 3);
AuthAccount member = merchantAccount(21L, 10001L, "DISABLED", null);
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of(owner, member));
service.archiveTenant(10001L);
assertEquals("DISABLED", owner.getAccountStatus());
assertEquals(4, owner.getTokenVersion());
assertEquals("DISABLED", member.getAccountStatus());
assertEquals(1, member.getTokenVersion());
verify(repository).save(owner);
verify(repository).save(member);
verify(sessions).revokeAll(20L);
verify(sessions).revokeAll(21L);
}
@Test void archiveTenantIsSuccessfulWhenCustomerHasNoAccounts() {
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of());
service.archiveTenant(10001L);
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
private AuthAccount merchantAccount(long id, long tenantId, String status, Integer tokenVersion) {
AuthAccount value = new AuthAccount();
value.setId(id);
value.setDefaultTenantId(tenantId);
value.setDefaultAppCode("MERCHANT");
value.setAccountStatus(status);
value.setTokenVersion(tokenVersion);
return value;
}
@Configuration @Configuration
static class TestConfig { static class TestConfig {
@Bean @Bean
@@ -16,6 +16,7 @@ import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -60,8 +61,8 @@ public class GlobalExceptionHandler {
return validationResponse(errors, request); return validationResponse(errors, request);
} }
@ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class, @ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class,
MethodArgumentTypeMismatchException.class}) MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class})
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
} }
@@ -11,6 +11,7 @@ import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -46,6 +47,19 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
} }
@Test
void missingRequiredRequestHeaderMapsToBadRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class);
MethodParameter parameter = new MethodParameter(method, 0);
var response = handler.handleBadRequest(
new MissingRequestHeaderException("Idempotency-Key", parameter), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
}
@Test @Test
void validationDetailsAreIncludedInMessageAndData() { void validationDetailsAreIncludedInMessageAndData() {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
@@ -223,7 +223,7 @@ deploy/
推荐顺序: 推荐顺序:
1. 外部基础设施:MySQL、Redis、Nacos;启用公共导出时再准备 RocketMQ。 1. 外部基础设施:MySQL、Redis、Nacos;启用公共导出时再准备 RocketMQ。
2. 核心认证:IAM、Auth。 2. 核心认证:Auth、IAM。
3. 主数据与适配:Warehouse、Integration。 3. 主数据与适配:Warehouse、Integration。
4. 业务依赖:Order、File。 4. 业务依赖:Order、File。
5. 核心编排与支撑:Firstleg、Print、Audit、Event Bridge。 5. 核心编排与支撑:Firstleg、Print、Audit、Event Bridge。
@@ -231,6 +231,32 @@ deploy/
Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序更容易判断故障来源。 Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序更容易判断故障来源。
### Auth/IAM RBAC 会话撤销发布验证
角色启停或角色权限全量变更会由 IAM 调用 Auth 的
`POST /internal/auth/accounts/sessions/revoke`,批量撤销受影响成员的 Access/Refresh 会话。
该契约发布时必须先部署 Auth,再部署 IAM,不得只更新 IAM。
发布后至少完成以下验证:
1. 确认 Auth、IAM 使用同一提交对应的镜像版本,且 IAM 能通过服务发现和内部服务令牌访问上述 Auth 内部接口;404、连接失败、内部令牌不一致或非成功业务码均不得放行 IAM 角色变更。
2. 为测试成员分配带订单查看权限的自定义角色,成员登录并确认订单列表可访问。
3. 停用该角色必须返回业务成功;继续使用停用前的 Access Token 访问订单列表必须被拒绝,旧 Refresh Token 也必须失效。
4. 成员重新登录后权限集合应为空;重新启用角色并再次登录后,原角色权限应恢复。
5. 修改角色权限集合后重复验证旧 Access/Refresh Token 失效,避免 `grantPermissions` 路径遗留旧权限。
2026-08-08 测试环境曾出现版本不一致:停用已分配角色返回 HTTP 200、业务码 `S0503`,
提示“认证会话撤销失败,角色变更未生效”。证据报告为
`reports/client-test-env-20260808021636-b5090589.json`,`requestId=client-e2e-d9e860c8-b061-43fd-b67b-196f76d95b3d`,
`traceId=f2812578869349b1b4393421498d05df`。该结果说明 IAM 已包含会话撤销调用,但 Auth
内部接口尚未部署或不可达;必须在 Auth 先行部署并通过连通性检查后重新执行上述验收。
本轮 Client 扩展测试的发布前最终基准为
`reports/client-test-env-20260808022313-1798dc6c.json`:共 150 项,143 通过、5 失败、2 跳过。
5 个阻断项分别为角色停用和角色权限收缩时 Auth/IAM 版本不一致导致 `S0503`、清空成员角色未撤销旧 JWT、
缺少 `Idempotency-Key` 返回 `S0500`、跨客户资质文件读取返回 `B0001`。订单条件必填 `A0422`、
已提交订单编辑 `A0409`、密码恢复和报告脱敏均已通过。后续发布验证应以该报告为对照,确认上述 5 项全部关闭。
## 9. 开发和拆分约束 ## 9. 开发和拆分约束
- 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。 - 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。
@@ -101,12 +101,18 @@ public class LocalQualificationStorage {
} }
public Resource load(long tenantId, String fileName) { public Resource load(long tenantId, String fileName) {
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) throw new BizException("Invalid file name"); if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) {
throw fileNotFound();
}
Path directory = tenantDirectory(tenantId); Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize(); Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found"); if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw fileNotFound();
try { return new UrlResource(target.toUri()); } try { return new UrlResource(target.toUri()); }
catch (Exception exception) { throw new BizException("File not found"); } catch (Exception exception) { throw fileNotFound(); }
}
private BizException fileNotFound() {
return new BizException(ResultCode.NOT_FOUND, "File not found");
} }
public void delete(StoredFile file) { public void delete(StoredFile file) {
@@ -60,7 +60,10 @@ class LocalQualificationStorageTest {
assertTrue(Files.isRegularFile(stored.path())); assertTrue(Files.isRegularFile(stored.path()));
assertEquals(stored.path().toAbsolutePath(), assertEquals(stored.path().toAbsolutePath(),
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
assertThrows(BizException.class, () -> storage.load(10002L, stored.fileName())); BizException crossTenant = assertThrows(BizException.class,
() -> storage.load(10002L, stored.fileName()));
assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode());
assertEquals("File not found", crossTenant.getMessage());
} }
@Test @Test
@@ -111,9 +114,22 @@ class LocalQualificationStorageTest {
@Test @Test
void legacySeedPathCannotBePretendedToBeAStoredFile() { void legacySeedPathCannotBePretendedToBeAStoredFile() {
BizException exception = assertThrows(BizException.class,
assertThrows(BizException.class,
() -> storage.load(10001L, "demo-business-license.jpg")); () -> storage.load(10001L, "demo-business-license.jpg"));
assertEquals(ResultCode.NOT_FOUND, exception.getResultCode());
assertEquals("File not found", exception.getMessage());
}
@Test
void missingQualificationReturnsNotFoundWithoutRevealingOwnership() {
storage.initialize();
BizException exception = assertThrows(BizException.class,
() -> storage.load(10001L, "00000000-0000-0000-0000-000000000000.pdf"));
assertEquals(ResultCode.NOT_FOUND, exception.getResultCode());
assertEquals("File not found", exception.getMessage());
} }
@Test @Test
@@ -4,8 +4,10 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse; import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.security.MeasurementRequestHeaders; import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService; import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
import com.baiyue.firstleg.service.MojiaMeasurementOpenApiService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
/** 第三方设备长宽高测量开放接口。 */ /** 第三方设备长宽高测量开放接口。 */
@@ -36,6 +39,21 @@ import org.springframework.web.multipart.MultipartFile;
public class OpenApiInboundMeasurementController { public class OpenApiInboundMeasurementController {
@Resource @Resource
private InboundMeasurementOpenApiService service; private InboundMeasurementOpenApiService service;
@Resource
private MojiaMeasurementOpenApiService mojiaService;
@Operation(summary = "墨家设备按箱码上传单箱测量结果",
description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自柏悦箱唛二维码")
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<CartonMeasurementResult> receiveMojia(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader(value = "X-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Nonce", required = false) String nonce,
@RequestHeader(value = "X-Signature", required = false) String signature,
@RequestBody String rawJson) {
return ApiResponse.success(mojiaService.receive(
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null), rawJson));
}
@Operation(summary = "提交一条第三方箱规测量结果和图片", @Operation(summary = "提交一条第三方箱规测量结果和图片",
description = "multipart 包含 data 原始JSON和 image 图片;联调关闭签名时只需固定 X-App-Key 和 Idempotency-Key,恢复后按六行 HMAC 规范签名") description = "multipart 包含 data 原始JSON和 image 图片;联调关闭签名时只需固定 X-App-Key 和 Idempotency-Key,恢复后按六行 HMAC 规范签名")
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 墨家设备按箱码上传的 JSON 协议。 */
public final class MojiaMeasurementDtos {
private MojiaMeasurementDtos() {
}
public record UploadRequest(
@NotBlank @Size(max = 128) String boxCode,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height,
@DecimalMin("0.001") @DecimalMax("999999999999999.999") BigDecimal weight,
@NotBlank @Size(max = 64) String machineNo,
LocalDateTime measuredAt,
@NotBlank @Size(max = 7_100_000) String picture
) {
}
}
@@ -0,0 +1,110 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadRequest;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Set;
/** 将墨家固定 AppKey + JSON/Base64 协议转换为统一单箱测量命令。 */
@Service
public class MojiaMeasurementOpenApiService {
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
private static final String JPEG_PREFIX = "data:image/jpeg;base64,";
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
private final MeasurementRequestAuthenticator authenticator;
private final CartonMeasurementService cartonMeasurements;
private final ObjectMapper objectMapper;
private final Validator validator;
public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator,
CartonMeasurementService cartonMeasurements,
ObjectMapper objectMapper,
Validator validator) {
this.authenticator = authenticator;
this.cartonMeasurements = cartonMeasurements;
this.objectMapper = objectMapper;
this.validator = validator;
}
public CartonMeasurementResult receive(MeasurementRequestHeaders headers, String rawJson) {
AuthenticatedMeasurementApp app = authenticator.authenticate(
"POST", CALLBACK_PATH, headers, rawJson, false);
UploadRequest request = parseAndValidate(rawJson);
MultipartFile image = decodeJpeg(request.picture());
return cartonMeasurements.upsert(app.tenantId(), app.subjectId(), new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(), image));
}
private UploadRequest parseAndValidate(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请求 JSON 不能为空");
}
final UploadRequest request;
try {
request = objectMapper.readValue(rawJson, UploadRequest.class);
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请求 JSON 格式错误");
}
Set<ConstraintViolation<UploadRequest>> violations = validator.validate(request);
if (!violations.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
violations.iterator().next().getPropertyPath() + " 参数不合法");
}
return request;
}
private MultipartFile decodeJpeg(String picture) {
if (!picture.startsWith(JPEG_PREFIX)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"picture 必须使用 data:image/jpeg;base64, 格式");
}
final byte[] bytes;
try {
bytes = Base64.getDecoder().decode(picture.substring(JPEG_PREFIX.length()));
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "picture Base64 内容无效");
}
if (bytes.length == 0 || bytes.length > MAX_IMAGE_BYTES
|| bytes.length < 3 || (bytes[0] & 0xff) != 0xff || (bytes[1] & 0xff) != 0xd8
|| (bytes[2] & 0xff) != 0xff) {
throw new BizException(ResultCode.VALIDATION_FAILED, "picture 不是有效的 JPEG 图片或超过5MB");
}
return new InMemoryMultipartFile(bytes);
}
private record InMemoryMultipartFile(byte[] content) implements MultipartFile {
@Override public String getName() { return "image"; }
@Override public String getOriginalFilename() { return "mojia-measurement.jpg"; }
@Override public String getContentType() { return "image/jpeg"; }
@Override public boolean isEmpty() { return content.length == 0; }
@Override public long getSize() { return content.length; }
@Override public byte[] getBytes() { return content.clone(); }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(content); }
@Override public void transferTo(File destination) throws IOException {
try (FileOutputStream output = new FileOutputStream(destination)) {
output.write(content);
}
}
}
}
@@ -236,6 +236,19 @@ paths:
requestBody: requestBody:
required: true required: true
content: content:
application/json:
schema:
type: object
required: [boxCode, length, width, height, machineNo, picture]
properties:
boxCode: {type: string, maxLength: 128, example: 'RK-2085911321565003778#4-1'}
length: {type: number, format: decimal, minimum: 0.001}
width: {type: number, format: decimal, minimum: 0.001}
height: {type: number, format: decimal, minimum: 0.001}
weight: {type: number, format: decimal, minimum: 0.001, nullable: true}
machineNo: {type: string, maxLength: 64}
measuredAt: {type: string, format: date-time, nullable: true}
picture: {type: string, description: 'data:image/jpeg;base64,...'}
multipart/form-data: multipart/form-data:
schema: schema:
type: object type: object
@@ -30,6 +30,19 @@ class OpenApiInboundMeasurementControllerContractTest {
assertThat(method.getParameters()[4].getAnnotation(RequestHeader.class).required()).isTrue(); assertThat(method.getParameters()[4].getAnnotation(RequestHeader.class).required()).isTrue();
} }
@Test
@DisplayName("墨家JSON入口只强制固定AppKey且保留原始JSON")
void shouldExposeMojiaJsonCompatibilityEntry() throws Exception {
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receiveMojia",
String.class, String.class, String.class, String.class, String.class);
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).required()).isTrue();
assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).required()).isFalse();
assertThat(method.getParameters()[2].getAnnotation(RequestHeader.class).required()).isFalse();
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).required()).isFalse();
assertThat(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).consumes())
.containsExactly("application/json");
}
@Test @Test
@DisplayName("静态OpenAPI声明回调、状态查询、签名和三层幂等") @DisplayName("静态OpenAPI声明回调、状态查询、签名和三层幂等")
void shouldDocumentMeasurementOpenApiContract() throws Exception { void shouldDocumentMeasurementOpenApiContract() throws Exception {
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MojiaMeasurementOpenApiServiceTest {
@Test
void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception {
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
CartonMeasurementService cartons = mock(CartonMeasurementService.class);
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator());
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of()));
when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult(
1L, 1, 2L, "RK-001#4-1", 1, new BigDecimal("60.1"), new BigDecimal("40.2"),
new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true));
CartonMeasurementResult result = service.receive(headers, json());
assertThat(result.boxCode()).isEqualTo("RK-001#4-1");
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
verify(cartons).upsert(eq(10001L), eq(90001L), command.capture());
assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1");
assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1");
assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg");
assertThat(command.getValue().image().getBytes()).containsExactly(0xff, 0xd8, 0xff, 0xd9);
}
private String json() {
return """
{"boxCode":"RK-001#4-1","length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="}
""";
}
}
@@ -10,6 +10,7 @@ import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CreationResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.entity.IamTenantReview;
@@ -98,15 +99,15 @@ public class PlatformTenantController {
@PostMapping("/tenants") @PostMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:create')") @PreAuthorize("hasAuthority('platform:customer:create')")
@Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft only. Saving the customer does not create a review ticket. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。") @Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft, its OWNER account and one-time credentials. Saving the customer does not create a review ticket. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"), @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 = "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 = "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 = "409", description = "Enterprise already exists / 企业已存在")
}) })
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request, public ApiResponse<CreationResponse> register(@Valid @RequestBody RegisterRequest request,
@AuthenticationPrincipal AuthenticatedUser user) { @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM"); TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.register(request, user.userId())); return ApiResponse.success(service.register(request, user.userId()));
} }
@@ -109,6 +109,13 @@ public final class TenantDtos {
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword @Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
) { } ) { }
@Schema(name = "TenantCreationResponse", description = "Created enterprise draft and one-time administrator credentials / 新建企业草稿及一次性管理员凭据")
public record CreationResponse(
@Schema(description = "Created enterprise draft / 新建企业草稿") TenantResponse tenant,
@Schema(description = "Contact phone used as login username / 使用联系电话作为登录账号") String administratorUsername,
@Schema(description = "One-time temporary password / 一次性初始密码", format = "password") String temporaryPassword
) { }
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
public record TenantResponse(Long id, String tenantCode, String tenantName, String status, public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
@@ -217,7 +217,16 @@ public class RbacService {
throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员"); throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员");
} }
} }
Set<Long> currentRoleIds = new LinkedHashSet<>();
for (Long roleId : repository.findMemberRoleIds(tenantId, request.membershipId())) {
if (repository.findRole(roleId, tenantId, appCode) != null) currentRoleIds.add(roleId);
}
repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds()); repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds());
if (!currentRoleIds.equals(request.roleIds())) {
var user = memberRepository.findUser(membership.getUserId());
if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable();
revokeSessions(List.of(user.getAuthUserId()));
}
} }
@BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE", @BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE",
@@ -238,7 +247,10 @@ public class RbacService {
} }
private void revokeAssignedSessions(long tenantId, Long roleId) { private void revokeAssignedSessions(long tenantId, Long roleId) {
List<Long> accountIds = repository.findAssignedAuthUserIds(tenantId, roleId); revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId));
}
private void revokeSessions(List<Long> accountIds) {
for (int start = 0; start < accountIds.size(); start += SESSION_REVOCATION_BATCH_SIZE) { for (int start = 0; start < accountIds.size(); start += SESSION_REVOCATION_BATCH_SIZE) {
Set<Long> batch = Set.copyOf(accountIds.subList(start, Set<Long> batch = Set.copyOf(accountIds.subList(start,
Math.min(start + SESSION_REVOCATION_BATCH_SIZE, accountIds.size()))); Math.min(start + SESSION_REVOCATION_BATCH_SIZE, accountIds.size())));
@@ -19,6 +19,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -48,6 +50,27 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
@Override @Override
public String name() { return "tenant-administrator"; } public String name() { return "tenant-administrator"; }
/** Creates the owner during platform customer creation and compensates Auth if the IAM transaction rolls back. */
public ProvisioningResult provisionPlatformCreatedTenant(IamTenant tenant, long operatorId) {
ApprovalContext context = new ApprovalContext(tenant, 0L, operatorId, "platform customer creation");
ProvisioningResult result = initialize(context);
if (result.created() && TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status != STATUS_ROLLED_BACK) return;
try {
compensate(context, result);
} catch (RuntimeException compensationFailure) {
log.error("平台创建客户事务回滚后认证账号补偿失败,tenantId={}, authUserId={}",
tenant.getId(), result.authUserId(), compensationFailure);
}
}
});
}
return result;
}
@Override @Override
public ProvisioningResult initialize(ApprovalContext context) { public ProvisioningResult initialize(ApprovalContext context) {
IamTenant tenant = context.tenant(); IamTenant tenant = context.tenant();
@@ -104,7 +127,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
.eq(IamRole::getStatus, "ACTIVE") .eq(IamRole::getStatus, "ACTIVE")
.last("LIMIT 1")); .last("LIMIT 1"));
if (ownerRole == null) { if (ownerRole == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能审核通过"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能创建或确认企业管理员");
} }
return ownerRole; return ownerRole;
} }
@@ -239,7 +262,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
private char pick(char[] source) { return source[random.nextInt(source.length)]; } private char pick(char[] source) { return source[random.nextInt(source.length)]; }
private BizException unavailable() { private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能审核通过"); return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能创建或确认企业管理员");
} }
private BizException inconsistentOwner(String message) { private BizException inconsistentOwner(String message) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message); return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
@@ -25,6 +25,7 @@ import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse; import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CreationResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
@@ -61,6 +62,8 @@ public class TenantService {
private TenantApprovalInitializationCoordinator approvalInitialization; private TenantApprovalInitializationCoordinator approvalInitialization;
@Resource @Resource
private CustomerDimensionRuleInitializationService dimensionRuleInitialization; private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource
private TenantAdministratorProvisioningService administratorProvisioning;
@Transactional @Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT", @BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
@@ -148,7 +151,7 @@ public class TenantService {
captureAfter = false captureAfter = false
) )
@Transactional @Transactional
public TenantResponse register(RegisterRequest request, long operatorId) { public CreationResponse register(RegisterRequest request, long operatorId) {
String contactPhone = normalizeContactPhone(request.contactPhone()); String contactPhone = normalizeContactPhone(request.contactPhone());
ensureContactPhoneAvailable(contactPhone, null); ensureContactPhoneAvailable(contactPhone, null);
IamTenant tenant = new IamTenant(); IamTenant tenant = new IamTenant();
@@ -177,8 +180,9 @@ public class TenantService {
tenant.setUpdatedBy(operatorId); tenant.setUpdatedBy(operatorId);
repository.insertTenant(tenant); repository.insertTenant(tenant);
dimensionRuleInitialization.initialize(tenant.getId(), operatorId); dimensionRuleInitialization.initialize(tenant.getId(), operatorId);
var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId);
return response(tenant); return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword());
} }
public List<IamTenantReview> pendingReviews() { public List<IamTenantReview> pendingReviews() {
+16 -2
View File
@@ -59,14 +59,14 @@ paths:
post: post:
tags: [Platform Tenant / 平台租户] tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业客户草稿 summary: Register enterprise tenant / 创建企业客户草稿
description: Create a `PROFILE_REQUIRED` enterprise draft only. Saving the customer does not create a review task. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。 description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: {$ref: '#/components/schemas/RegisterRequest'} schema: {$ref: '#/components/schemas/RegisterRequest'}
responses: responses:
'200': {$ref: '#/components/responses/TenantSuccess'} '200': {$ref: '#/components/responses/TenantCreationSuccess'}
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'} '409': {$ref: '#/components/responses/Conflict'}
@@ -822,6 +822,7 @@ components:
responses: responses:
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
TenantSuccess: {description: Enterprise tenant returned / 返回企业客户档案, content: {application/json: {schema: {$ref: '#/components/schemas/TenantApiResponse'}}}} TenantSuccess: {description: Enterprise tenant returned / 返回企业客户档案, content: {application/json: {schema: {$ref: '#/components/schemas/TenantApiResponse'}}}}
TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}}
BadRequest: {description: Invalid request / 请求参数错误, 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'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
@@ -869,6 +870,19 @@ components:
- type: object - type: object
properties: properties:
data: {$ref: '#/components/schemas/TenantApprovalResponse'} data: {$ref: '#/components/schemas/TenantApprovalResponse'}
TenantCreationApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/TenantCreationResponse'}
TenantCreationResponse:
type: object
required: [tenant, administratorUsername, temporaryPassword]
properties:
tenant: {$ref: '#/components/schemas/TenantResponse'}
administratorUsername: {type: string, example: '13800138000'}
temporaryPassword: {type: string, format: password, description: 一次性初始密码,仅本次创建响应返回}
TenantApprovalResponse: TenantApprovalResponse:
type: object type: object
required: [tenantId, administratorCreated] required: [tenantId, administratorCreated]
@@ -66,6 +66,16 @@ class PlatformTenantControllerContractTest {
returnType.getActualTypeArguments()[0]); returnType.getActualTypeArguments()[0]);
} }
@Test
void platformCreationReturnsTenantAndOneTimeAdministratorCredentials() throws Exception {
Method method = PlatformTenantController.class.getMethod("register",
com.baiyue.iam.dto.TenantDtos.RegisterRequest.class, AuthenticatedUser.class);
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
assertEquals(com.baiyue.iam.dto.TenantDtos.CreationResponse.class,
returnType.getActualTypeArguments()[0]);
}
@Test @Test
void shouldExposeCustomerOwnerPasswordResetWithDedicatedPermission() throws Exception { void shouldExposeCustomerOwnerPasswordResetWithDedicatedPermission() throws Exception {
Method method = PlatformTenantController.class.getMethod("resetOwnerPassword", long.class, Method method = PlatformTenantController.class.getMethod("resetOwnerPassword", long.class,
@@ -15,6 +15,7 @@ import com.baiyue.iam.dto.RbacDtos.ChangePermissionStatusRequest;
import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamPermission; import com.baiyue.iam.entity.IamPermission;
import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.RbacRepository; import com.baiyue.iam.repository.RbacRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -363,6 +364,47 @@ class RbacServiceTest {
assertEquals("认证会话撤销失败,角色变更未生效", exception.getMessage()); assertEquals("认证会话撤销失败,角色变更未生效", exception.getMessage());
} }
@Test
void clearingMemberRolesRevokesThatMembersExistingSessions() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
membership.setUserId(41L);
membership.setMemberType("EMPLOYEE");
IamUser user = new IamUser();
user.setId(41L);
user.setAuthUserId(501L);
when(memberRepository.findById(31L)).thenReturn(membership);
when(memberRepository.findUser(41L)).thenReturn(user);
when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L));
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.success(null));
service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of()));
verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of());
verify(authClient).revokeSessions(eq("test-internal-token"),
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L))));
}
@Test
void savingUnchangedMemberRolesDoesNotRevokeSessions() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
membership.setMemberType("EMPLOYEE");
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
when(memberRepository.findById(31L)).thenReturn(membership);
when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L));
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role);
service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L)));
verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of(11L));
verifyNoInteractions(authClient);
}
private IamRole role(Long id, Long tenantId, String appCode, String roleType) { private IamRole role(Long id, Long tenantId, String appCode, String roleType) {
IamRole role = new IamRole(); IamRole role = new IamRole();
role.setId(id); role.setId(id);
@@ -9,6 +9,7 @@ import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -18,6 +19,8 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.List; import java.util.List;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -43,6 +46,13 @@ class TenantAdministratorProvisioningServiceTest {
@Resource private TenantAdministratorProvisioningService service; @Resource private TenantAdministratorProvisioningService service;
private IamTenant tenant; private IamTenant tenant;
@AfterEach
void clearSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test @Test
void springContextCreatesBean() { void springContextCreatesBean() {
assertNotNull(service); assertNotNull(service);
@@ -93,6 +103,20 @@ class TenantAdministratorProvisioningServiceTest {
verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L)));
} }
@Test
void platformCreationRollbackCompensatesTheNewAuthAccount() {
TransactionSynchronizationManager.initSynchronization();
var result = service.provisionPlatformCreatedTenant(tenant, 7000L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
assertTrue(result.created());
verify(auth).compensate("internal-token", result.authUserId());
}
@Test @Test
void existingOwnerSkipsProvisioningAndDoesNotReturnCredentials() { void existingOwnerSkipsProvisioningAndDoesNotReturnCredentials() {
IamMembership owner = activeOwner(); IamMembership owner = activeOwner();
@@ -26,6 +26,7 @@ class TenantCustomerExportTest {
@MockBean private AuthAccountClient authAccountClient; @MockBean private AuthAccountClient authAccountClient;
@MockBean private TenantApprovalInitializationCoordinator approvalInitialization; @MockBean private TenantApprovalInitializationCoordinator approvalInitialization;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private TenantAdministratorProvisioningService administratorProvisioning;
@Resource private TenantService service; @Resource private TenantService service;
@Test @Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() { void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
@@ -222,6 +222,9 @@ class TenantServiceTest {
@Test @Test
void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() {
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "13800138000", "Password1234"))
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest( var created = service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
@@ -229,12 +232,15 @@ class TenantServiceTest {
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
9001L); 9001L);
assertEquals("PROFILE_REQUIRED", created.status()); assertEquals("PROFILE_REQUIRED", created.tenant().status());
assertEquals("Bob", created.legalRepresentative()); assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.servicePackage()); assertEquals("基础版", created.tenant().servicePackage());
assertEquals("13800138000", created.administratorUsername());
assertEquals("Password1234", created.temporaryPassword());
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); && Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
verify(dimensionRuleInitialization).initialize(created.id(), 9001L); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L);
verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
verify(repository, never()).insertReview(any()); verify(repository, never()).insertReview(any());
} }
@@ -253,16 +259,33 @@ class TenantServiceTest {
@Test @Test
void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "13800138000", "Password1234"))
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest( var created = service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"alice@example.com", "基础版", null, null, 30, null, "alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L); null, null, "CNY", null, null), 9001L);
assertEquals(null, created.unifiedSocialCreditCode()); assertEquals(null, created.tenant().unifiedSocialCreditCode());
verify(repository).insertTenant(argThat(tenant -> tenant.getUnifiedSocialCreditCode() == null)); verify(repository).insertTenant(argThat(tenant -> tenant.getUnifiedSocialCreditCode() == null));
} }
@Test
void profileRequiredDraftArchiveDisablesAuthAccountsAndCustomer() {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(authClient.archiveTenant("internal-token", 10001L))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(null));
var archived = service.archiveMerchant(10001L);
assertEquals("DISABLED", archived.status());
verify(authClient).archiveTenant("internal-token", 10001L);
verify(repository).saveTenant(tenant);
}
@Test @Test
void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() { void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
@@ -0,0 +1,780 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808021813228",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T02:18:35.352Z",
"startedAt": "2026-08-08T02:18:13.228Z",
"success": false,
"fatal": null,
"summary": {
"total": 39,
"passed": 36,
"failed": 3
},
"modules": [
{
"name": "orders",
"startedAt": "2026-08-08T02:18:13.605Z",
"finishedAt": "2026-08-08T02:18:35.262Z",
"success": false,
"passed": 35,
"failed": 2,
"fatal": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接"
},
{
"name": "inbound-weight-ocr",
"startedAt": "2026-08-08T02:18:35.265Z",
"finishedAt": "2026-08-08T02:18:35.352Z",
"success": false,
"passed": 0,
"failed": 1,
"fatal": null
}
],
"scenarios": [
{
"name": "weight-image-recognition-and-confirmation",
"module": "inbound-weight-ocr",
"success": false,
"deploymentBlocked": false,
"passed": 0,
"failed": 1
}
],
"deploymentBlockers": [
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"evidence": "20秒内未返回 inboundId",
"code": "S0503",
"requestId": "438a12d6-5829-4fa2-8574-bc2c403c9f75",
"traceId": "9b51ea540a7c4a1f8566eab65620001c"
}
],
"createdResources": {
"orders": {
"marker": "ADMIN-API-20260808021813228",
"tenantId": "10001",
"orderId": "2085913442075082754",
"productImageFileId": "2085913441529823234",
"boxTypes": [
"SMALL",
"MEDIUM",
"LARGE",
"EXTRA_LARGE"
],
"inboundId": null,
"orderNo": "FL2085913442075082754"
}
},
"excluded": [
{
"scope": "dashboard",
"reason": "首页为静态演示数据,不调用后端 API"
},
{
"scope": "hidden-prototypes",
"reason": "隐藏原型页面尚无已确认后端契约"
}
],
"securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。",
"steps": [
{
"module": "authentication",
"scenario": null,
"name": "平台管理员登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d724b317-473f-40bd-bde9-60aca7e0098c, 8b97525e-c8d8-4c7d-a5d5-323259556645",
"traceId": "8627c96e270446d4b5fec252ac976e64",
"evidence": "",
"checkedAt": "2026-08-08T02:18:13.604Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试账号登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "57860ec6-be78-4066-9964-b7fe1b9e5496, 7c21b6f0-25c7-4773-964d-87309affd9e4",
"traceId": "2fe3f787c5ed4856b34b72cd4d8831ea",
"evidence": "username=merchant_admin",
"checkedAt": "2026-08-08T02:18:13.956Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询当前企业资料",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "00b18b0a-89c4-47c8-b861-03725eb1c3fc, 5a026290-cb2e-42d7-9342-e2c76953f974",
"traceId": "2068755a0728458b81792de5e31d3ef8",
"evidence": "",
"checkedAt": "2026-08-08T02:18:13.980Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试企业已审核且客户 ID 有效",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "00b18b0a-89c4-47c8-b861-03725eb1c3fc, 5a026290-cb2e-42d7-9342-e2c76953f974",
"traceId": "2068755a0728458b81792de5e31d3ef8",
"evidence": "tenantId=10001, status=ACTIVE",
"checkedAt": "2026-08-08T02:18:13.981Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Admin 查询客户固定箱型",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "18221254-531c-475b-8aa6-b3601f371f1f, 22ac6187-eda0-433f-9a34-c24ea03f5af2",
"traceId": "85a09e0acbd64f7d80355872710240f7",
"evidence": "tenantId=10001",
"checkedAt": "2026-08-08T02:18:14.017Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "固定四箱型顺序、内径及每边加2cm正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "18221254-531c-475b-8aa6-b3601f371f1f, 22ac6187-eda0-433f-9a34-c24ea03f5af2",
"traceId": "85a09e0acbd64f7d80355872710240f7",
"evidence": "types=SMALL,MEDIUM,LARGE,EXTRA_LARGE",
"checkedAt": "2026-08-08T02:18:14.017Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "016c5f6a-bb16-4171-ac6f-98c6b4e7b61c, c1bbc087-4846-4fa6-90bb-2ffdae9f7f45",
"traceId": "8c7e016f470c4c8fbb6b601f0d1a428f",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.054Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "016c5f6a-bb16-4171-ac6f-98c6b4e7b61c, c1bbc087-4846-4fa6-90bb-2ffdae9f7f45",
"traceId": "8c7e016f470c4c8fbb6b601f0d1a428f",
"evidence": "count=2",
"checkedAt": "2026-08-08T02:18:14.054Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d811ee69-eca6-4ed9-b28b-2d252dd15cf9, 7050232a-d912-4b26-95b5-5665321ea314",
"traceId": "35dc511159184c06b5d873a0860ea259",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.123Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d811ee69-eca6-4ed9-b28b-2d252dd15cf9, 7050232a-d912-4b26-95b5-5665321ea314",
"traceId": "35dc511159184c06b5d873a0860ea259",
"evidence": "count=200",
"checkedAt": "2026-08-08T02:18:14.123Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询可用计费渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "59cfb9db-9ead-4174-a505-42883a8a1eb6, 01d1377a-15cc-4856-9ffc-ff70d1db3f91",
"traceId": "546da0b26ac44057881949356a38a6eb",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.171Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在带计价税制的可用渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "59cfb9db-9ead-4174-a505-42883a8a1eb6, 01d1377a-15cc-4856-9ffc-ff70d1db3f91",
"traceId": "546da0b26ac44057881949356a38a6eb",
"evidence": "count=15",
"checkedAt": "2026-08-08T02:18:14.171Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询发货客户候选",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/partners/candidates?type=SHIPPER"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "11946d37-21fe-468d-af44-7884f1df696e, 09f2cadf-629c-4569-bf41-c5b05e931cc9",
"traceId": "3b323ee01419461d89fa412a444714e8",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.208Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 上传产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "21a2b983-d4f6-43e7-a464-71fb938ef7a5, bade6d3f-6516-46e6-b7d6-afa4fbab4e1a",
"traceId": "19b4ecce4ebd4b868348822fea67a6f5",
"evidence": "contentType=image/png",
"checkedAt": "2026-08-08T02:18:14.254Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "产品图片上传返回字符串文件 ID",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "21a2b983-d4f6-43e7-a464-71fb938ef7a5, bade6d3f-6516-46e6-b7d6-afa4fbab4e1a",
"traceId": "19b4ecce4ebd4b868348822fea67a6f5",
"evidence": "fileIdType=string, contentType=image/png",
"checkedAt": "2026-08-08T02:18:14.254Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 可下载本轮产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/files/product-images/2085913441529823234"
},
"http": 200,
"code": null,
"message": null,
"requestId": "b8d16fdc-892e-495f-9dbd-7f150681498f, f898d1e5-6ecc-4240-81dc-4cc792198f33",
"traceId": "bc13aba31663407dbdf11e058535d422",
"evidence": "bytes=68, contentType=image/png",
"checkedAt": "2026-08-08T02:18:14.282Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 创建四箱型隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "ea9fa6cf-9722-41c6-a9f8-461669f8452c, ac93d499-e32e-4bcd-8204-4219c40be970",
"traceId": "3009b5a516044fedb0e6648d3221f492",
"evidence": "marker=ADMIN-API-20260808021813228",
"checkedAt": "2026-08-08T02:18:14.395Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建订单幂等重放",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "9c5bd574-0b44-4a77-816c-433b7d2e404e, 9380027e-8502-4ca3-88ec-f4317543f6f9",
"traceId": "51baa17264144b9685976cbc4f457223",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.441Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建幂等结果一致",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "9c5bd574-0b44-4a77-816c-433b7d2e404e, 9380027e-8502-4ca3-88ec-f4317543f6f9",
"traceId": "51baa17264144b9685976cbc4f457223",
"evidence": "orderId=2085913442075082754, replayedId=2085913442075082754",
"checkedAt": "2026-08-08T02:18:14.442Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询隔离订单草稿",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "e47a2e37-3346-4bd5-a8f3-03cf0300af63, ec85ecdf-92f1-44d1-8bea-49f908697ae8",
"traceId": "6531eafc40ba48ff96d96cd5ef02779b",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.471Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 草稿身份、marker 和状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "e47a2e37-3346-4bd5-a8f3-03cf0300af63, ec85ecdf-92f1-44d1-8bea-49f908697ae8",
"traceId": "6531eafc40ba48ff96d96cd5ef02779b",
"evidence": "orderNo=FL2085913442075082754, status=DRAFT",
"checkedAt": "2026-08-08T02:18:14.471Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "e47a2e37-3346-4bd5-a8f3-03cf0300af63, ec85ecdf-92f1-44d1-8bea-49f908697ae8",
"traceId": "6531eafc40ba48ff96d96cd5ef02779b",
"evidence": "cargoes=4, imageFileId=2085913441529823234",
"checkedAt": "2026-08-08T02:18:14.471Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 提交隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg/2085913442075082754/submit"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6c67230a-d6ed-4978-8a27-8484ef7ecbb5, a05bda79-4452-496e-8125-c81abc496f94",
"traceId": "d333673147bc451cbb936505d7c90307",
"evidence": "orderId=2085913442075082754",
"checkedAt": "2026-08-08T02:18:14.567Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 回查已提交订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d8c6d729-93a0-4f90-830d-2b65d4b2f6d1, 7c39c36a-a49a-45ce-a4fa-e3a13ff5a1e5",
"traceId": "d113de3e006c423394763a2820aa39a9",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.605Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 订单已进入待审核状态",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d8c6d729-93a0-4f90-830d-2b65d4b2f6d1, 7c39c36a-a49a-45ce-a4fa-e3a13ff5a1e5",
"traceId": "d113de3e006c423394763a2820aa39a9",
"evidence": "status=SUBMITTED",
"checkedAt": "2026-08-08T02:18:14.606Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "19e756f5-9cc0-4300-b025-4245a0a32d3c, 97f23a47-1059-40a0-86e8-e3adff12fa24",
"traceId": "9957f741d6534e89902a9ec47adfc218",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.670Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085913442075082754"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "19e756f5-9cc0-4300-b025-4245a0a32d3c, 97f23a47-1059-40a0-86e8-e3adff12fa24",
"traceId": "9957f741d6534e89902a9ec47adfc218",
"evidence": "orderId=2085913442075082754, found=true",
"checkedAt": "2026-08-08T02:18:14.670Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085913442075082754?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "a7810bfb-197c-408e-a30d-0ac9671238f3, 039f2fe0-a7d7-4795-9df9-fee060df4eee",
"traceId": "502c16f4e4734299a865a9d4556826a9",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.716Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单详情身份与待审核状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085913442075082754?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "a7810bfb-197c-408e-a30d-0ac9671238f3, 039f2fe0-a7d7-4795-9df9-fee060df4eee",
"traceId": "502c16f4e4734299a865a9d4556826a9",
"evidence": "mainStatus=PENDING_REVIEW, timeline=2",
"checkedAt": "2026-08-08T02:18:14.716Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085913442075082754&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "a5b659b5-091e-4369-a790-ca8d97ab7941, 2a30d9de-0321-45b2-9442-477d63bfa199",
"traceId": "27dc5a7046d04e27873d1ac5f5d386cd",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.764Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085913442075082754&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "a5b659b5-091e-4369-a790-ca8d97ab7941, 2a30d9de-0321-45b2-9442-477d63bfa199",
"traceId": "27dc5a7046d04e27873d1ac5f5d386cd",
"evidence": "orderId=2085913442075082754, found=true",
"checkedAt": "2026-08-08T02:18:14.764Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085913442075082754?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "2cd85740-18e2-4db8-857a-92c98f02178a, 35e8fdb1-72e1-4277-b8b0-78a2a87d1b53",
"traceId": "76ff576142194f15b6e6a763cd81b620",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.809Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单详情聚合结构完整",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085913442075082754?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "2cd85740-18e2-4db8-857a-92c98f02178a, 35e8fdb1-72e1-4277-b8b0-78a2a87d1b53",
"traceId": "76ff576142194f15b6e6a763cd81b620",
"evidence": "cargoes=4, history=2",
"checkedAt": "2026-08-08T02:18:14.809Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085913442075082754?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "2cd85740-18e2-4db8-857a-92c98f02178a, 35e8fdb1-72e1-4277-b8b0-78a2a87d1b53",
"traceId": "76ff576142194f15b6e6a763cd81b620",
"evidence": "cargoes=4, imageFileId=2085913441529823234",
"checkedAt": "2026-08-08T02:18:14.809Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 为本轮订单添加内部备注",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085913442075082754/internal-notes?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f9aff324-5034-443d-86d4-2d7df44f1eee, 7deb42b2-d8ec-452b-8f21-283c04307f22",
"traceId": "e9e97c8a04fe4468bd76597413af4ba5",
"evidence": "",
"checkedAt": "2026-08-08T02:18:14.855Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 审核通过本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085913442075082754/approve?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "1aab8371-7451-41dc-a140-5d50cc3883bf, 94ccf6a2-cd6f-459d-9c50-ecb25d146240",
"traceId": "a25a0014f79d4181a2b3e677a82f00d5",
"evidence": "orderId=2085913442075082754",
"checkedAt": "2026-08-08T02:18:14.904Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085913442075082754?tenantId=10001"
},
"http": 200,
"code": "S0503",
"message": "统一订单履约详情响应无效",
"requestId": "438a12d6-5829-4fa2-8574-bc2c403c9f75",
"traceId": "9b51ea540a7c4a1f8566eab65620001c",
"evidence": "20秒内未返回 inboundId",
"checkedAt": "2026-08-08T02:18:35.262Z"
},
{
"module": "orders",
"scenario": null,
"name": "模块执行",
"passed": false,
"deploymentBlocker": false,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接",
"checkedAt": "2026-08-08T02:18:35.262Z"
},
{
"module": "inbound-weight-ocr",
"scenario": "weight-image-recognition-and-confirmation",
"name": "仅选择带本轮测试标记的可编辑入库单",
"passed": false,
"deploymentBlocker": false,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "marker=ADMIN-API-20260808021813228, 未找到本轮创建且状态为 WAIT_MEASURE/MEASURING/MEASURE_REJECTED 的入库单",
"checkedAt": "2026-08-08T02:18:35.352Z"
}
]
}
@@ -0,0 +1,780 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808022421690",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T02:24:43.508Z",
"startedAt": "2026-08-08T02:24:21.690Z",
"success": false,
"fatal": null,
"summary": {
"total": 39,
"passed": 36,
"failed": 3
},
"modules": [
{
"name": "orders",
"startedAt": "2026-08-08T02:24:22.065Z",
"finishedAt": "2026-08-08T02:24:43.430Z",
"success": false,
"passed": 35,
"failed": 2,
"fatal": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接"
},
{
"name": "inbound-weight-ocr",
"startedAt": "2026-08-08T02:24:43.432Z",
"finishedAt": "2026-08-08T02:24:43.507Z",
"success": false,
"passed": 0,
"failed": 1,
"fatal": null
}
],
"scenarios": [
{
"name": "weight-image-recognition-and-confirmation",
"module": "inbound-weight-ocr",
"success": false,
"deploymentBlocked": false,
"passed": 0,
"failed": 1
}
],
"deploymentBlockers": [
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"evidence": "20秒内未返回 inboundId",
"code": "S0503",
"requestId": "04d51502-8c78-4256-8232-fbdb394936c5",
"traceId": "86df65f5590641378ebc2db3ac7b03fb"
}
],
"createdResources": {
"orders": {
"marker": "ADMIN-API-20260808022421690",
"tenantId": "10001",
"orderId": "2085914987172790274",
"productImageFileId": "2085914986782720002",
"boxTypes": [
"SMALL",
"MEDIUM",
"LARGE",
"EXTRA_LARGE"
],
"inboundId": null,
"orderNo": "FL2085914987172790274"
}
},
"excluded": [
{
"scope": "dashboard",
"reason": "首页为静态演示数据,不调用后端 API"
},
{
"scope": "hidden-prototypes",
"reason": "隐藏原型页面尚无已确认后端契约"
}
],
"securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。",
"steps": [
{
"module": "authentication",
"scenario": null,
"name": "平台管理员登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "943c5461-6750-433f-a8b4-6b79e93c76e3, f67c7aff-3029-4deb-b65a-98db1f114533",
"traceId": "4c61fff1847748e39ec168932743bc91",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.064Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试账号登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0967d15b-80d1-40f4-a2c0-df18768f62ed, 9f03ad0e-cb57-47ce-901b-024619e903c5",
"traceId": "c849964284624bb294817ae885f92039",
"evidence": "username=merchant_admin",
"checkedAt": "2026-08-08T02:24:22.408Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询当前企业资料",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "768d4c29-4af9-4ef8-8287-3708015b8208, 43860360-0488-49da-8d5e-8d1e38b4e9fd",
"traceId": "9491a2adc03f4b31b3ed007510917b85",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.442Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试企业已审核且客户 ID 有效",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "768d4c29-4af9-4ef8-8287-3708015b8208, 43860360-0488-49da-8d5e-8d1e38b4e9fd",
"traceId": "9491a2adc03f4b31b3ed007510917b85",
"evidence": "tenantId=10001, status=ACTIVE",
"checkedAt": "2026-08-08T02:24:22.442Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Admin 查询客户固定箱型",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "df4138d6-5477-4dad-9928-d0c72b3c8613, 58416f8f-123e-4fa6-910c-5fa8a027d87b",
"traceId": "fcd206c8b6874922bfc646c5decb7259",
"evidence": "tenantId=10001",
"checkedAt": "2026-08-08T02:24:22.476Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "固定四箱型顺序、内径及每边加2cm正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "df4138d6-5477-4dad-9928-d0c72b3c8613, 58416f8f-123e-4fa6-910c-5fa8a027d87b",
"traceId": "fcd206c8b6874922bfc646c5decb7259",
"evidence": "types=SMALL,MEDIUM,LARGE,EXTRA_LARGE",
"checkedAt": "2026-08-08T02:24:22.477Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6ebbc640-96a5-4b05-b3a0-25eb1365a6a4, 8ca09a7b-ba63-49b6-b675-66cfef2d847f",
"traceId": "a4767fd87fff4811b9e9ae3533073166",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.511Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6ebbc640-96a5-4b05-b3a0-25eb1365a6a4, 8ca09a7b-ba63-49b6-b675-66cfef2d847f",
"traceId": "a4767fd87fff4811b9e9ae3533073166",
"evidence": "count=2",
"checkedAt": "2026-08-08T02:24:22.511Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "44636c1e-976d-4f44-83d3-931b137ffb64, 1b0bb465-9459-4395-bde8-a8bf5f4a022d",
"traceId": "54a12164b4bc447e83e8f0117c636ea7",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.567Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "44636c1e-976d-4f44-83d3-931b137ffb64, 1b0bb465-9459-4395-bde8-a8bf5f4a022d",
"traceId": "54a12164b4bc447e83e8f0117c636ea7",
"evidence": "count=200",
"checkedAt": "2026-08-08T02:24:22.567Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询可用计费渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "64265bef-7ed3-42bd-beeb-31c48039131b, 70c0d763-6ffb-4add-b65f-0420c2e6a346",
"traceId": "8e2913d003354085bd5efee3c21b40a8",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.608Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在带计价税制的可用渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "64265bef-7ed3-42bd-beeb-31c48039131b, 70c0d763-6ffb-4add-b65f-0420c2e6a346",
"traceId": "8e2913d003354085bd5efee3c21b40a8",
"evidence": "count=15",
"checkedAt": "2026-08-08T02:24:22.608Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询发货客户候选",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/partners/candidates?type=SHIPPER"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "62149605-53dc-4a96-b612-362d21369c53, 58efa9bc-5106-4450-a350-5f9f9aefe68d",
"traceId": "ffef0f71bbad4d999692769984a46c7b",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.633Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 上传产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "2fab9590-3f70-4102-a392-430367f42d63, c1badbdc-8ae2-4054-b1db-5928a49d663f",
"traceId": "a6b24c7736ce4d4c8763151cc08e399d",
"evidence": "contentType=image/png",
"checkedAt": "2026-08-08T02:24:22.672Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "产品图片上传返回字符串文件 ID",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "2fab9590-3f70-4102-a392-430367f42d63, c1badbdc-8ae2-4054-b1db-5928a49d663f",
"traceId": "a6b24c7736ce4d4c8763151cc08e399d",
"evidence": "fileIdType=string, contentType=image/png",
"checkedAt": "2026-08-08T02:24:22.672Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 可下载本轮产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/files/product-images/2085914986782720002"
},
"http": 200,
"code": null,
"message": null,
"requestId": "2d17d24c-915b-4f96-876e-b50cdfbba4e4, 256b2563-b130-4eeb-8b4d-7bfa47433400",
"traceId": "c26aafa3e4bb459b9c3d39dbc911ac6f",
"evidence": "bytes=68, contentType=image/png",
"checkedAt": "2026-08-08T02:24:22.699Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 创建四箱型隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7186ad91-c27c-4f12-9021-1f4c3a9da8ea, ded88a07-0e88-4cb3-8e6b-532a74d8203f",
"traceId": "ce3576180fea4d5b99ef690c40ef8483",
"evidence": "marker=ADMIN-API-20260808022421690",
"checkedAt": "2026-08-08T02:24:22.774Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建订单幂等重放",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f7e1e9f2-19b4-4dc8-9ab6-755d9d283f6c, e64b3147-f636-4dd4-b6df-495161c52faf",
"traceId": "1d467e403a044d31b21b9f9fccbfac45",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.812Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建幂等结果一致",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f7e1e9f2-19b4-4dc8-9ab6-755d9d283f6c, e64b3147-f636-4dd4-b6df-495161c52faf",
"traceId": "1d467e403a044d31b21b9f9fccbfac45",
"evidence": "orderId=2085914987172790274, replayedId=2085914987172790274",
"checkedAt": "2026-08-08T02:24:22.812Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询隔离订单草稿",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "20038c2f-603c-47b7-88bf-9ca843e26e0a, 11b67acf-700e-427e-bd65-4df7b1b7989a",
"traceId": "ce1dcf9630cc4aa0a09d9eebf6fbc654",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.851Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 草稿身份、marker 和状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "20038c2f-603c-47b7-88bf-9ca843e26e0a, 11b67acf-700e-427e-bd65-4df7b1b7989a",
"traceId": "ce1dcf9630cc4aa0a09d9eebf6fbc654",
"evidence": "orderNo=FL2085914987172790274, status=DRAFT",
"checkedAt": "2026-08-08T02:24:22.851Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "20038c2f-603c-47b7-88bf-9ca843e26e0a, 11b67acf-700e-427e-bd65-4df7b1b7989a",
"traceId": "ce1dcf9630cc4aa0a09d9eebf6fbc654",
"evidence": "cargoes=4, imageFileId=2085914986782720002",
"checkedAt": "2026-08-08T02:24:22.851Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 提交隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg/2085914987172790274/submit"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "77433166-1284-4934-a067-4207989e7acd, d3267d9d-c93b-40b1-87bc-8cd2d46fbd73",
"traceId": "2e7779366c36499496ef5a2696aa137b",
"evidence": "orderId=2085914987172790274",
"checkedAt": "2026-08-08T02:24:22.925Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 回查已提交订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5e1b2991-cc03-48ca-b30a-326f18f15b70, 5b9d1711-e72e-4385-8777-abc09ac50f88",
"traceId": "832c08f20e334930837d23e98e2b33f0",
"evidence": "",
"checkedAt": "2026-08-08T02:24:22.958Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 订单已进入待审核状态",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5e1b2991-cc03-48ca-b30a-326f18f15b70, 5b9d1711-e72e-4385-8777-abc09ac50f88",
"traceId": "832c08f20e334930837d23e98e2b33f0",
"evidence": "status=SUBMITTED",
"checkedAt": "2026-08-08T02:24:22.958Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d9fb62c1-5545-439b-baa6-e1aa6fef5c7c, 7cfc3839-5f10-4eb4-a3be-ea904587c52e",
"traceId": "5c5e483e980f4d6397c41b0e5a1ba778",
"evidence": "",
"checkedAt": "2026-08-08T02:24:23.000Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085914987172790274"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d9fb62c1-5545-439b-baa6-e1aa6fef5c7c, 7cfc3839-5f10-4eb4-a3be-ea904587c52e",
"traceId": "5c5e483e980f4d6397c41b0e5a1ba778",
"evidence": "orderId=2085914987172790274, found=true",
"checkedAt": "2026-08-08T02:24:23.000Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085914987172790274?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "06d182cc-c800-4742-9919-24d8fd47e51d, 620f4ed9-25e3-4467-89fb-b569c47f858d",
"traceId": "77d2e16fb915485494f8a336a985d653",
"evidence": "",
"checkedAt": "2026-08-08T02:24:23.035Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单详情身份与待审核状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085914987172790274?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "06d182cc-c800-4742-9919-24d8fd47e51d, 620f4ed9-25e3-4467-89fb-b569c47f858d",
"traceId": "77d2e16fb915485494f8a336a985d653",
"evidence": "mainStatus=PENDING_REVIEW, timeline=2",
"checkedAt": "2026-08-08T02:24:23.035Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085914987172790274&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "58946f29-d1a9-4b39-82f0-2f18182b0e3f, 92f2fc9b-90cd-4ba9-99d1-724989577ea3",
"traceId": "614821dbea17445a8df42a34c456db18",
"evidence": "",
"checkedAt": "2026-08-08T02:24:23.069Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085914987172790274&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "58946f29-d1a9-4b39-82f0-2f18182b0e3f, 92f2fc9b-90cd-4ba9-99d1-724989577ea3",
"traceId": "614821dbea17445a8df42a34c456db18",
"evidence": "orderId=2085914987172790274, found=true",
"checkedAt": "2026-08-08T02:24:23.069Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085914987172790274?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7632e3b7-8d97-451f-a909-82e8ab222976, 1b9250b7-9936-4466-a3c7-5446e373b545",
"traceId": "08369be7b131463dadabc7b0b58c66e6",
"evidence": "",
"checkedAt": "2026-08-08T02:24:23.100Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单详情聚合结构完整",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085914987172790274?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7632e3b7-8d97-451f-a909-82e8ab222976, 1b9250b7-9936-4466-a3c7-5446e373b545",
"traceId": "08369be7b131463dadabc7b0b58c66e6",
"evidence": "cargoes=4, history=2",
"checkedAt": "2026-08-08T02:24:23.100Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085914987172790274?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7632e3b7-8d97-451f-a909-82e8ab222976, 1b9250b7-9936-4466-a3c7-5446e373b545",
"traceId": "08369be7b131463dadabc7b0b58c66e6",
"evidence": "cargoes=4, imageFileId=2085914986782720002",
"checkedAt": "2026-08-08T02:24:23.100Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 为本轮订单添加内部备注",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085914987172790274/internal-notes?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0d3a2d57-9c8e-48b5-8923-b727e2b71f3d, 7221a9de-feb0-4d0a-9618-a5d543209e71",
"traceId": "f7c958a6b67745c0b3273016718a7f15",
"evidence": "",
"checkedAt": "2026-08-08T02:24:23.136Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 审核通过本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085914987172790274/approve?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3b1f6fcf-2e9c-4401-824a-3cf429ea4510, c30de6cb-5311-4be4-9f2e-32bd75e7795c",
"traceId": "24b4f3f614b049698d1bd443f346f5ed",
"evidence": "orderId=2085914987172790274",
"checkedAt": "2026-08-08T02:24:23.187Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085914987172790274?tenantId=10001"
},
"http": 200,
"code": "S0503",
"message": "统一订单履约详情响应无效",
"requestId": "04d51502-8c78-4256-8232-fbdb394936c5",
"traceId": "86df65f5590641378ebc2db3ac7b03fb",
"evidence": "20秒内未返回 inboundId",
"checkedAt": "2026-08-08T02:24:43.430Z"
},
{
"module": "orders",
"scenario": null,
"name": "模块执行",
"passed": false,
"deploymentBlocker": false,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接",
"checkedAt": "2026-08-08T02:24:43.430Z"
},
{
"module": "inbound-weight-ocr",
"scenario": "weight-image-recognition-and-confirmation",
"name": "仅选择带本轮测试标记的可编辑入库单",
"passed": false,
"deploymentBlocker": false,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "marker=ADMIN-API-20260808022421690, 未找到本轮创建且状态为 WAIT_MEASURE/MEASURING/MEASURE_REJECTED 的入库单",
"checkedAt": "2026-08-08T02:24:43.507Z"
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -718,12 +718,13 @@ async function rbacAndMemberModule() {
) )
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) })) }, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
await apiStep('menu-permissions', 'disable assigned role', { const disabledRole = await softApiStep('menu-permissions', 'disable assigned role', {
method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' }, method: 'PUT', path: `/api/merchant/v1/iam/roles/${roleId}/status`, token: merchantToken, body: { status: 'DISABLED' },
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertEqual(json.data?.status, 'DISABLED', 'role status') assertEqual(json.data?.status, 'DISABLED', 'role status')
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) }, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
if (disabledRole) {
await softApiStep('menu-permissions', 'disabled role is reflected by live permission query', { await softApiStep('menu-permissions', 'disabled role is reflected by live permission query', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
}, ({ json, status }) => { }, ({ json, status }) => {
@@ -779,11 +780,16 @@ async function rbacAndMemberModule() {
assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions') assertSetEqual(arrayData(json.data), desiredCodes, 'member backend permissions')
assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], 'visible Client menus') assertSetEqual(visibleClientMenus(arrayData(json.data)), ['account:profile:view', 'dashboard:view', 'order:first-leg:list'], 'visible Client menus')
}, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) })) }, ({ json }) => ({ backendPermissions: json.data, visibleClientMenus: visibleClientMenus(arrayData(json.data)) }))
} else {
recordSkipped('menu-permissions', 'role disable dependent authorization checks',
'Role status change failed; dependent checks require a successfully disabled role')
}
await apiStep('menu-permissions', 'remove order view permission from assigned role', { const reducedPermissions = await softApiStep('menu-permissions', 'remove order view permission from assigned role', {
method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken, method: 'PUT', path: '/api/merchant/v1/iam/roles/permissions', token: merchantToken,
body: { roleId, permissionIds: [orderListPermissionId] }, body: { roleId, permissionIds: [orderListPermissionId] },
}, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] })) }, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: [desiredCodes[0]] }))
if (reducedPermissions) {
await softApiStep('menu-permissions', 'permission removal revokes stale member token', { await softApiStep('menu-permissions', 'permission removal revokes stale member token', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken, method: 'GET', path: `/api/merchant/v1/orders/first-leg/${report.resources.orderIds[0]}`, token: memberToken,
}, ({ json, status }) => { }, ({ json, status }) => {
@@ -828,6 +834,10 @@ async function rbacAndMemberModule() {
requireSuccess(json) requireSuccess(json)
assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id') assertEqual(String(json.data?.order?.id), String(report.resources.orderIds[0]), 'order id')
}, ({ json }) => ({ orderId: json.data.order.id })) }, ({ json }) => ({ orderId: json.data.order.id }))
} else {
recordSkipped('menu-permissions', 'permission removal dependent authorization checks',
'Role permission update failed; dependent checks require a successfully reduced permission set')
}
const editedMemberBody = { const editedMemberBody = {
displayName: `已编辑订单成员-${marker}`.slice(0, 64), displayName: `已编辑订单成员-${marker}`.slice(0, 64),
@@ -908,7 +918,7 @@ async function rbacAndMemberModule() {
assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id') assertEqual(String(json.data?.membershipId), String(membershipId), 'membership id')
assertEqual(arrayData(json.data?.roleIds).length, 0, 'member role count') assertEqual(arrayData(json.data?.roleIds).length, 0, 'member role count')
}, ({ json }) => ({ membershipId: json.data.membershipId, roleIds: json.data.roleIds })) }, ({ json }) => ({ membershipId: json.data.membershipId, roleIds: json.data.roleIds }))
await apiStep('rbac-member', 'role clearing revokes stale member token', { await softApiStep('rbac-member', 'role clearing revokes stale member token', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json, status }) => { }, ({ json, status }) => {
assertEqual(status, 401, 'HTTP status') assertEqual(status, 401, 'HTTP status')
@@ -1195,7 +1205,7 @@ async function orderLifecycleModule() {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken, method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20&status=UNKNOWN', token: merchantToken,
}, ({ json, status }) => { }, ({ json, status }) => {
assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`) assertTrue([200, 400].includes(status), `unexpected HTTP status ${status}`)
assertEqual(String(json?.code), 'A0400', 'business code') assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
await apiStep('order-boundary', 'unknown order detail is rejected', { await apiStep('order-boundary', 'unknown order detail is rejected', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg/1', token: merchantToken, method: 'GET', path: '/api/merchant/v1/orders/first-leg/1', token: merchantToken,
@@ -1398,9 +1408,12 @@ async function accountSecurityModule() {
assertEqual(status, 200, 'HTTP status') assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code') assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ rejectionCode: json.code })) }, ({ json }) => ({ rejectionCode: json.code }))
await apiStep('account-security', 'repeated logout remains idempotent', { await apiStep('account-security', 'repeated logout with revoked session is rejected', {
method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken }, method: 'POST', path: '/api/auth/logout', body: { refreshToken: loggedOutRefreshToken },
}, ({ json }) => requireSuccess(json), () => ({ repeated: true })) }, ({ json, status }) => {
assertEqual(status, 401, 'HTTP status')
assertEqual(String(json?.code), 'A0401', 'business code')
}, ({ json }) => ({ repeated: true, rejectionCode: json?.code }))
} }
async function recoverOwnerPassword() { async function recoverOwnerPassword() {