fix(file): bind loading evidence to cabinet

This commit is contained in:
2026-08-31 17:33:11 +08:00
parent ba8e3595e4
commit 1288c8937c
10 changed files with 359 additions and 57 deletions
+2 -2
View File
@@ -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`,不得包装成业务错误。
@@ -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<LoadingEvidenceService.UploadResult> 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<LoadingEvidenceService.FileMetadata> 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<byte[]> 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()
@@ -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<LoadingEvidenceService.UploadResult> 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<byte[]> 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()
@@ -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<String> 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<FileObjectEntity>()
.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) {}
}
+2 -2
View File
@@ -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 '存储提供方',
@@ -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'
@@ -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");
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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");
}
}