From 1288c8937cb32c316eec43396fb3ca6bf693f65e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 31 Aug 2026 17:33:11 +0800 Subject: [PATCH] fix(file): bind loading evidence to cabinet --- file/AGENTS.md | 4 +- .../InternalLoadingEvidenceController.java | 24 +-- .../PlatformLoadingEvidenceController.java | 20 +- .../file/service/LoadingEvidenceService.java | 44 ++-- file/src/main/resources/db/file.sql | 4 +- .../openapi/loading-evidence-v1.yaml | 196 ++++++++++++++++++ .../LoadingEvidenceFileContractTest.java | 26 +++ ...InternalLoadingEvidenceControllerTest.java | 16 +- ...PlatformLoadingEvidenceControllerTest.java | 55 +++++ .../service/LoadingEvidenceServiceTest.java | 27 ++- 10 files changed, 359 insertions(+), 57 deletions(-) create mode 100644 file/src/main/resources/openapi/loading-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java create mode 100644 file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 2e99c5889..247f16d6a 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -14,7 +14,7 @@ | 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` | | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | -| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | +| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | @@ -25,7 +25,7 @@ ## 修改约束 - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 -- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。 +- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。 - 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。 - 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 - 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 diff --git a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java index bd34ddc72..252f8cf20 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java @@ -25,32 +25,32 @@ public class InternalLoadingEvidenceController { @PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } - @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse upload( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId, @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) { requireToken(token); - if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0"); - return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file)); + if (containerBookingId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和操作主体ID必须大于0"); + return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file)); } - @GetMapping("/{tenantId}/{inboundId}/{fileId}") + @GetMapping("/{containerBookingId}/{fileId}") public ApiResponse validate( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { + @PathVariable long containerBookingId, @PathVariable long fileId) { requireToken(token); - if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); - return ApiResponse.success(service.validate(tenantId, inboundId, fileId)); + if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0"); + return ApiResponse.success(service.validate(containerBookingId, fileId)); } - @GetMapping("/{tenantId}/{inboundId}/{fileId}/content") + @GetMapping("/{containerBookingId}/{fileId}/content") public ResponseEntity download( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { + @PathVariable long containerBookingId, @PathVariable long fileId) { requireToken(token); - if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); - var value = service.download(tenantId, inboundId, fileId); + if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0"); + var value = service.download(containerBookingId, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java index b7901c4ea..f6483b82a 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java @@ -20,21 +20,21 @@ import java.nio.charset.StandardCharsets; public class PlatformLoadingEvidenceController { @Resource private LoadingEvidenceService service; - @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')") + @PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:loading:edit','platform:firstleg:pda:work')") public ApiResponse upload( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId, + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long containerBookingId, @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) { - long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId); - return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file)); + long operatorId = requirePlatform(user); requirePositive(containerBookingId); + return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file)); } - @GetMapping("/{tenantId}/{inboundId}/{fileId}") - @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')") + @GetMapping("/{containerBookingId}/{fileId}") + @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:loading:view','platform:firstleg:pda:work')") public ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { - requirePlatform(user); requirePositive(tenantId, inboundId, fileId); - var value = service.download(tenantId, inboundId, fileId); + @PathVariable long containerBookingId, @PathVariable long fileId) { + requirePlatform(user); requirePositive(containerBookingId, fileId); + var value = service.download(containerBookingId, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() diff --git a/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java b/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java index 7c87b214a..602ed5a26 100644 --- a/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java +++ b/file/src/main/java/com/baiyue/file/service/LoadingEvidenceService.java @@ -11,22 +11,28 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; +import java.util.Set; /** 装柜四步照片和铅封证据的受控文件入口。 */ @Service public class LoadingEvidenceService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final String PLATFORM_OWNER_SCOPE = "PLATFORM"; + private static final Set BUSINESS_TYPES = Set.of( + "LOADING_PHOTO_STEP_1", "LOADING_PHOTO_STEP_2", "LOADING_PHOTO_STEP_3", + "LOADING_PHOTO_STEP_4", "LOADING_SEAL"); @Resource private MeasurementImageStorage storage; @Resource private FileObjectMapper files; @Transactional - public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType, + public UploadResult upload(long containerBookingId, long operatorId, String evidenceType, MultipartFile file) { validateType(evidenceType); - var stored = storage.storeLoading(tenantId, inboundId, file); + var stored = storage.storeLoading(PLATFORM_TENANT_ID, containerBookingId, file); FileObjectEntity object = new FileObjectEntity(); - object.setTenantId(tenantId); + object.setTenantId(PLATFORM_TENANT_ID); object.setBusinessType("LOADING_" + evidenceType); - object.setBusinessId(Long.toString(inboundId)); + object.setBusinessId(Long.toString(containerBookingId)); object.setOriginalName(stored.originalName()); object.setObjectKey(stored.objectKey()); object.setStorageProvider("LOCAL"); @@ -39,24 +45,28 @@ public class LoadingEvidenceService { object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now()); object.setDeleted(false); object.setVersion(0); files.insert(object); - return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getBusinessType()); + return new UploadResult(object.getId(), PLATFORM_OWNER_SCOPE, object.getTenantId(), + object.getBusinessType(), object.getBusinessId(), object.getOriginalName(), + object.getContentType(), object.getSizeBytes()); } - public FileMetadata validate(long tenantId, long inboundId, long fileId) { - FileObjectEntity object = requireObject(tenantId, inboundId, fileId); - return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType()); + public FileMetadata validate(long containerBookingId, long fileId) { + FileObjectEntity object = requireObject(containerBookingId, fileId); + return new FileMetadata(object.getId(), PLATFORM_OWNER_SCOPE, object.getTenantId(), + object.getBusinessType(), object.getBusinessId(), object.getOriginalName(), object.getContentType()); } - public Download download(long tenantId, long inboundId, long fileId) { - FileObjectEntity object = requireObject(tenantId, inboundId, fileId); + public Download download(long containerBookingId, long fileId) { + FileObjectEntity object = requireObject(containerBookingId, fileId); return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); } - private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) { + private FileObjectEntity requireObject(long containerBookingId, long fileId) { FileObjectEntity object = files.selectOne(new LambdaQueryWrapper() - .eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getId, fileId) - .eq(FileObjectEntity::getBusinessId, Long.toString(inboundId)) - .likeRight(FileObjectEntity::getBusinessType, "LOADING_") + .eq(FileObjectEntity::getTenantId, PLATFORM_TENANT_ID).eq(FileObjectEntity::getId, fileId) + .eq(FileObjectEntity::getBusinessId, Long.toString(containerBookingId)) + .in(FileObjectEntity::getBusinessType, BUSINESS_TYPES) + .eq(FileObjectEntity::getStatus, "ACTIVE") .eq(FileObjectEntity::getDeleted, false)); if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); return object; @@ -68,7 +78,9 @@ public class LoadingEvidenceService { } } - public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {} - public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {} + public record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType, Long sizeBytes) {} + public record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType, + String businessId, String fileName, String contentType) {} public record Download(String fileName, String contentType, byte[] content) {} } diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index d9fd2da3b..7c11d291a 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -5,9 +5,9 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', business_type VARCHAR(32) NULL COMMENT '关联业务类型', - business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', + business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方', diff --git a/file/src/main/resources/openapi/loading-evidence-v1.yaml b/file/src/main/resources/openapi/loading-evidence-v1.yaml new file mode 100644 index 000000000..235088853 --- /dev/null +++ b/file/src/main/resources/openapi/loading-evidence-v1.yaml @@ -0,0 +1,196 @@ +openapi: 3.0.3 +info: + title: Loading Evidence File API + version: 1.0.0 + description: >- + 装柜四步照片与铅封文件只归属物理柜。所有文件固定 ownerScope=PLATFORM、tenantId=0、 + businessId=containerBookingId;PDA taskId 仅由 Firstleg 保存为来源和审计,不参与 File 归属。 +servers: + - url: / +tags: + - name: LoadingEvidence +paths: + /api/platform/v1/files/loading-evidence/{containerBookingId}: + post: + tags: [LoadingEvidence] + summary: 平台或PDA上传柜级装柜证据 + description: 不接受客户ID或入仓单ID;同一文件不得跨containerBookingId复用。 + x-required-any-permission: + - platform:firstleg:inbound:loading:edit + - platform:firstleg:pda:work + parameters: + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/EvidenceType' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': + description: 上传结果 + content: + application/json: + schema: {$ref: '#/components/schemas/UploadResponse'} + /api/platform/v1/files/loading-evidence/{containerBookingId}/{fileId}: + get: + tags: [LoadingEvidence] + summary: 平台或PDA下载柜级装柜证据 + description: 必须同时命中平台所有权、containerBookingId、fileId、LOADING_*类型和ACTIVE状态;响应禁止缓存。 + x-required-any-permission: + - platform:firstleg:inbound:loading:view + - platform:firstleg:pda:work + parameters: + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 文件内容 + headers: + Cache-Control: + schema: {type: string, enum: [no-store]} + content: + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + image/bmp: {schema: {type: string, format: binary}} + application/octet-stream: {schema: {type: string, format: binary}} + /internal/file/v1/loading-evidence/{containerBookingId}: + post: + tags: [LoadingEvidence] + summary: Firstleg内部上传柜级装柜证据 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/OperatorId' + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/EvidenceType' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': + description: 上传结果 + content: + application/json: + schema: {$ref: '#/components/schemas/UploadResponse'} + /internal/file/v1/loading-evidence/{containerBookingId}/{fileId}: + get: + tags: [LoadingEvidence] + summary: Firstleg内部校验柜级文件元数据 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataResponse'} + /internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content: + get: + tags: [LoadingEvidence] + summary: Firstleg内部下载柜级文件 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/ContainerBookingId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 文件内容,Cache-Control固定no-store + headers: + Cache-Control: + schema: {type: string, enum: [no-store]} + content: + application/octet-stream: {schema: {type: string, format: binary}} +components: + parameters: + InternalToken: + in: header + name: X-Internal-Service-Token + required: true + schema: {type: string} + OperatorId: + in: header + name: X-Operator-Id + required: true + schema: {$ref: '#/components/schemas/Id'} + ContainerBookingId: + in: path + name: containerBookingId + required: true + schema: {$ref: '#/components/schemas/Id'} + FileId: + in: path + name: fileId + required: true + schema: {$ref: '#/components/schemas/Id'} + EvidenceType: + in: query + name: evidenceType + required: true + schema: + type: string + enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] + schemas: + Id: + type: string + pattern: '^[1-9][0-9]*$' + ApiEnvelope: + type: object + required: [code, message, requestId, traceId, timestamp] + properties: + code: {type: string} + message: {type: string} + requestId: {type: string} + traceId: {type: string} + timestamp: {type: string, format: date-time} + UploadResponse: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + required: [data] + properties: + data: {$ref: '#/components/schemas/UploadResult'} + MetadataResponse: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + required: [data] + properties: + data: {$ref: '#/components/schemas/FileMetadata'} + FileIdentity: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, contentType] + properties: + fileId: {$ref: '#/components/schemas/Id'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: {type: string, enum: ['0']} + businessType: + type: string + enum: [LOADING_PHOTO_STEP_1, LOADING_PHOTO_STEP_2, LOADING_PHOTO_STEP_3, LOADING_PHOTO_STEP_4, LOADING_SEAL] + businessId: + allOf: + - $ref: '#/components/schemas/Id' + description: 物理containerBookingId + fileName: {type: string} + contentType: {type: string} + UploadResult: + allOf: + - $ref: '#/components/schemas/FileIdentity' + - type: object + required: [sizeBytes] + properties: + sizeBytes: {type: integer, format: int64, minimum: 1} + FileMetadata: + allOf: + - $ref: '#/components/schemas/FileIdentity' diff --git a/file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java b/file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java new file mode 100644 index 000000000..24f1c9f71 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/LoadingEvidenceFileContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.file.config; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingEvidenceFileContractTest { + @Test + void freezesPhysicalContainerOwnershipWithoutInboundCompatibilityPath() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/loading-evidence-v1.yaml")); + + assertThat(yaml) + .contains("/api/platform/v1/files/loading-evidence/{containerBookingId}:") + .contains("/internal/file/v1/loading-evidence/{containerBookingId}:") + .contains("ownerScope: {type: string, enum: [PLATFORM]}") + .contains("tenantId: {type: string, enum: ['0']}") + .contains("description: 物理containerBookingId") + .contains("LOADING_PHOTO_STEP_1", "LOADING_PHOTO_STEP_2", "LOADING_PHOTO_STEP_3", + "LOADING_PHOTO_STEP_4", "LOADING_SEAL") + .contains("x-required-any-permission:", "platform:firstleg:pda:work", "Cache-Control固定no-store") + .doesNotContain("/{tenantId}/{inboundId}", "businessId=inboundId"); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java index 6fb7bfd37..207fece60 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java @@ -21,32 +21,32 @@ class InternalLoadingEvidenceControllerTest { @Resource InternalLoadingEvidenceController controller; @Test void downloadsOwnedEvidenceBehindInternalToken() { - when(service.download(81L, 91L, 101L)).thenReturn( + when(service.download(91L, 101L)).thenReturn( new LoadingEvidenceService.Download("door.jpg", "image/jpeg", new byte[]{1, 2, 3})); - var response = controller.download("internal-token", 81L, 91L, 101L); + var response = controller.download("internal-token", 91L, 101L); assertThat(response.getBody()).containsExactly(1, 2, 3); assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg"); assertThat(response.getHeaders().getCacheControl()).contains("no-store"); - verify(service).download(81L, 91L, 101L); + verify(service).download(91L, 101L); } @Test void rejectsInvalidTokenBeforeReadingEvidence() { - assertThatThrownBy(() -> controller.download("wrong", 81L, 91L, 101L)) + assertThatThrownBy(() -> controller.download("wrong", 91L, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); verifyNoInteractions(service); } @Test void fallsBackForInvalidOrNonImageContentTypes() { - when(service.download(81L, 91L, 101L)).thenReturn( + when(service.download(91L, 101L)).thenReturn( new LoadingEvidenceService.Download("door.jpg", "not a/media/type", new byte[]{1})); - when(service.download(81L, 91L, 102L)).thenReturn( + when(service.download(91L, 102L)).thenReturn( new LoadingEvidenceService.Download("door.txt", "text/plain", new byte[]{2})); - assertThat(controller.download("internal-token", 81L, 91L, 101L).getHeaders().getContentType()) + assertThat(controller.download("internal-token", 91L, 101L).getHeaders().getContentType()) .isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); - assertThat(controller.download("internal-token", 81L, 91L, 102L).getHeaders().getContentType()) + assertThat(controller.download("internal-token", 91L, 102L).getHeaders().getContentType()) .isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); } } diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java new file mode 100644 index 000000000..98f66f018 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/PlatformLoadingEvidenceControllerTest.java @@ -0,0 +1,55 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.LoadingEvidenceService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformLoadingEvidenceControllerTest { + private final LoadingEvidenceService service = mock(LoadingEvidenceService.class); + private final PlatformLoadingEvidenceController controller = new PlatformLoadingEvidenceController(); + + PlatformLoadingEvidenceControllerTest() throws Exception { + var field = PlatformLoadingEvidenceController.class.getDeclaredField("service"); + field.setAccessible(true); + field.set(controller, service); + } + + @Test + void uploadsPlatformOwnedContainerEvidence() { + var file = new MockMultipartFile("file", "door.jpg", "image/jpeg", new byte[]{1}); + when(service.upload(91L, 7L, "PHOTO_STEP_1", file)).thenReturn( + new LoadingEvidenceService.UploadResult(101L, "PLATFORM", 0L, + "LOADING_PHOTO_STEP_1", "91", "door.jpg", "image/jpeg", 1L)); + + var response = controller.upload(platform(), 91L, "PHOTO_STEP_1", file); + + assertThat(response.data()).extracting(LoadingEvidenceService.UploadResult::ownerScope, + LoadingEvidenceService.UploadResult::tenantId, LoadingEvidenceService.UploadResult::businessId) + .containsExactly("PLATFORM", 0L, "91"); + verify(service).upload(91L, 7L, "PHOTO_STEP_1", file); + } + + @Test + void rejectsMerchantBeforeAccessingContainerEvidence() { + assertThatThrownBy(() -> controller.download(merchant(), 91L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("平台端身份"); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 7L, "operator", "PLATFORM", "PDA", Set.of()); + } + + private static AuthenticatedUser merchant() { + return new AuthenticatedUser(8L, 88L, 88L, 8L, "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java b/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java index b8283d848..261c18594 100644 --- a/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/LoadingEvidenceServiceTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.ArgumentCaptor; @ExtendWith(MockitoExtension.class) class LoadingEvidenceServiceTest { @@ -28,24 +29,36 @@ class LoadingEvidenceServiceTest { @Test void uploadsEvidenceWithDedicatedBusinessType() { var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1}); - when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored( - "loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg")); - var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart); + when(storage.storeLoading(0, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored( + "loading-evidence/0/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg")); + var result = service.upload(20, 7, "PHOTO_STEP_2", multipart); assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2"); - verify(files).insert(any(FileObjectEntity.class)); + assertThat(result.ownerScope()).isEqualTo("PLATFORM"); + assertThat(result.tenantId()).isZero(); + assertThat(result.businessId()).isEqualTo("20"); + var object = ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files).insert(object.capture()); + assertThat(object.getValue()).extracting(FileObjectEntity::getTenantId, + FileObjectEntity::getBusinessId, FileObjectEntity::getBucketName) + .containsExactly(0L, "20", "loading-evidence"); } @Test void rejectsUnknownEvidenceType() { var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1}); - assertThatThrownBy(() -> service.upload(10, 20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.upload(20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class); } @Test void validatesTenantBoundFile() { FileObjectEntity object = new FileObjectEntity(); - object.setId(30L); object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg"); + object.setId(30L); object.setTenantId(0L); object.setBusinessId("20"); + object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg"); when(files.selectOne(any(Wrapper.class))).thenReturn(object); - assertThat(service.validate(10, 20, 30).fileId()).isEqualTo(30L); + var metadata = service.validate(20, 30); + assertThat(metadata.fileId()).isEqualTo(30L); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); + assertThat(metadata.tenantId()).isZero(); + assertThat(metadata.businessId()).isEqualTo("20"); } }