fix(file): bind loading evidence to cabinet
This commit is contained in:
+12
-12
@@ -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()
|
||||
|
||||
+10
-10
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user