feat(firstleg): implement container customs workflow

This commit is contained in:
2026-08-28 16:48:50 +08:00
parent bb9673358d
commit 0ea4aeecc9
92 changed files with 3165 additions and 2754 deletions
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
check(token);
checkTenantId(tenantId);
var value = service.download(tenantId, businessType, businessId, fileId);
String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", "");
MediaType contentType;
try {
contentType = MediaType.parseMediaType(value.contentType());
} catch (IllegalArgumentException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"")
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length)
.body(value.content());
}
@@ -0,0 +1,52 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
CUSTOMS_DOCUMENT(false, false),
CUSTOMS_RECEIPT(false, false),
FINANCE_VOUCHER(false, false),
CONTAINER_CLEARANCE_DOCUMENT(false, false),
LOADING_CUSTOMS_DECLARATION(false, false),
CONTAINER_DELIVERY_ORDER(false, false),
CONTAINER_PICKUP_ORDER(false, false),
OVERSEAS_UNLOAD_PHOTO(true, false),
OVERSEAS_RELABEL_PHOTO(true, false),
LOADING_LIST_CUSTOMS_SOURCE(true, true),
LOADING_LIST_CUSTOMS_RESULT(true, true);
static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024;
static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024;
private final boolean customerOwned;
private final boolean arbitraryFormat;
BusinessFileCategory(boolean customerOwned, boolean arbitraryFormat) {
this.customerOwned = customerOwned;
this.arbitraryFormat = arbitraryFormat;
}
static BusinessFileCategory require(String value) {
return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst()
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
}
void validateOwner(long tenantId) {
if (customerOwned && tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
}
}
long maxSizeBytes() {
return arbitraryFormat ? CUSTOMS_MAX_SIZE : STANDARD_MAX_SIZE;
}
boolean arbitraryFormat() {
return arbitraryFormat;
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@@ -21,7 +22,6 @@ import java.util.UUID;
@Component
public class BusinessFileStorage {
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png");
@@ -35,13 +35,13 @@ public class BusinessFileStorage {
root = configuredRoot.toAbsolutePath().normalize();
}
Stored storeLoading(long tenantId, long businessId, MultipartFile file) {
Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
String extension = TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type);
if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
String type = safeContentType(file.getContentType());
String extension = arbitraryFormat ? "" : TYPES.get(type);
if (!arbitraryFormat && extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
if (!arbitraryFormat) verifyContent(file, type);
String name = safeName(file);
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
@@ -55,6 +55,15 @@ public class BusinessFileStorage {
}
}
private static String safeContentType(String contentType) {
if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE;
try {
return MediaType.parseMediaType(contentType).toString().toLowerCase(Locale.ROOT);
} catch (IllegalArgumentException exception) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
byte[] read(String objectKey) {
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
@@ -91,7 +100,12 @@ public class BusinessFileStorage {
private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename();
return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString();
if (name == null || name.isBlank()) return "attachment";
String normalized = name.replace('\\', '/');
normalized = normalized.substring(normalized.lastIndexOf('/') + 1)
.replaceAll("[\\p{Cntrl}]", "").trim();
if (normalized.isBlank()) return "attachment";
return normalized.length() <= 255 ? normalized : normalized.substring(normalized.length() - 255);
}
private static String sha256(Path path) throws IOException {
@@ -0,0 +1,47 @@
package com.baiyue.file.service;
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import java.util.List;
final class ExportSchemaContracts {
static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS";
static final List<ExpectedColumn> BUY_DECLARATION_COLUMNS = List.of(
column("customerName", "客户名称", ExportColumnType.STRING),
column("orderNo", "订单号", ExportColumnType.STRING),
column("inboundCode", "入仓码", ExportColumnType.STRING),
column("sku", "SKU", ExportColumnType.STRING),
column("chineseName", "中文品名", ExportColumnType.STRING),
column("englishName", "英文品名", ExportColumnType.STRING),
column("cartonCount", "箱数", ExportColumnType.INTEGER),
column("productQuantity", "产品数量", ExportColumnType.INTEGER),
column("grossWeight", "毛重", ExportColumnType.DECIMAL),
column("netWeight", "净重", ExportColumnType.DECIMAL),
column("volume", "体积", ExportColumnType.DECIMAL),
column("mark", "唛头", ExportColumnType.STRING),
column("customerRemark", "客户备注", ExportColumnType.STRING));
private ExportSchemaContracts() { }
static void validate(String exportType, List<ExportColumn> columns) {
if (!BUY_DECLARATION_ITEMS.equals(exportType)) return;
if (columns.size() != BUY_DECLARATION_COLUMNS.size()) {
throw ExportFailure.permanent("买单报关导出列定义不匹配");
}
for (int index = 0; index < columns.size(); index++) {
ExportColumn actual = columns.get(index);
ExpectedColumn expected = BUY_DECLARATION_COLUMNS.get(index);
if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title())
|| expected.type != actual.type()) {
throw ExportFailure.permanent("买单报关导出列定义不匹配");
}
}
}
private static ExpectedColumn column(String key, String title, ExportColumnType type) {
return new ExpectedColumn(key, title, type);
}
record ExpectedColumn(String key, String title, ExportColumnType type) { }
}
@@ -45,6 +45,7 @@ public class ExportTaskService {
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
}
validateParameters(request.parameters());
validateTypeParameters(type, request.parameters());
String parametersJson = parametersJson(request.parameters());
long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
@@ -176,6 +177,21 @@ public class ExportTaskService {
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效");
}
private void validateTypeParameters(String type, Map<String, Object> parameters) {
if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return;
Map<String, Object> values = parameters == null ? Map.of() : parameters;
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "买单报关导出仅接受 loadingPlanId");
}
Object raw = values.get("loadingPlanId");
try {
if (!(raw instanceof String text) || !text.matches("[1-9][0-9]*")
|| Long.parseLong(text) <= 0) throw new NumberFormatException();
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "loadingPlanId 必须为正整数字符串");
}
}
private ExportTaskResponse response(FileExportTask task) {
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
@@ -88,7 +88,9 @@ public class GenericXlsxExportRunner {
if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
throw ExportFailure.permanent("Provider 首批契约无效");
}
columns = validateColumns(batch.columns()); columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
columns = validateColumns(batch.columns());
ExportSchemaContracts.validate(task.getExportType(), columns);
columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
sheet = newSheet(workbook, 1, columns);
} else if (!schemaHash.equals(batch.schemaHash())) {
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
@@ -10,22 +10,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.Set;
@Service
public class InternalBusinessFileService {
private static final Set<String> BUSINESS_TYPES = Set.of(
"TRUCK_APPOINTMENT_ATTACHMENT",
"CUSTOMS_DOCUMENT",
"CUSTOMS_RECEIPT",
"FINANCE_VOUCHER",
"CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DECLARATION",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO",
"OVERSEAS_RELABEL_PHOTO");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
@@ -34,9 +20,10 @@ public class InternalBusinessFileService {
if (tenantId < 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
}
validateBusinessType(businessType);
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
long parsedBusinessId = parseBusinessId(businessId);
var stored = storage.storeLoading(tenantId, parsedBusinessId, file);
var stored = storage.store(tenantId, parsedBusinessId, file, category.maxSizeBytes(), category.arbitraryFormat());
FileObjectEntity value = new FileObjectEntity();
value.setTenantId(tenantId);
value.setBusinessType(businessType);
@@ -89,16 +76,11 @@ public class InternalBusinessFileService {
if (tenantId < 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
}
validateBusinessType(businessType);
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
parseBusinessId(businessId);
}
private static void validateBusinessType(String businessType) {
if (!BUSINESS_TYPES.contains(businessType)) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型");
}
}
private static long parseBusinessId(String businessId) {
final long value;
try {
+11 -2
View File
@@ -40,8 +40,8 @@ spring:
read-timeout: 15000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 11MB
max-file-size: 50MB
max-request-size: 51MB
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer:
@@ -101,6 +101,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
@@ -0,0 +1,154 @@
openapi: 3.0.3
info:
title: File Customs Business File and Export Contract
version: 1.0.0
description: |
File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。
Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。
LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0;
客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。
用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:loading-list:list;
Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。
买单客户是否有结果入口由 Firstleg 判定,File 不据此增加结果文件或下载入口。
paths:
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}:
post:
summary: 保存报关业务文件对象
description: |
仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。
支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/OperatorId'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FileCategory'
- $ref: '#/components/parameters/LoadingPlanId'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200':
description: ApiResponse<BusinessFileMetadata>
content:
application/json:
schema: {$ref: '#/components/schemas/BusinessFileResponse'}
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}:
get:
summary: 校验报关文件对象的精确复合归属
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FileCategory'
- $ref: '#/components/parameters/LoadingPlanId'
- $ref: '#/components/parameters/FileId'
responses:
'200':
description: ApiResponse<BusinessFileMetadata>
content:
application/json:
schema: {$ref: '#/components/schemas/BusinessFileResponse'}
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}/content:
get:
summary: 下载精确归属的报关文件对象
description: 响应固定为 attachment 且 Cache-Control 包含 no-store;用户可见性由 Firstleg 在调用前判定。
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FileCategory'
- $ref: '#/components/parameters/LoadingPlanId'
- $ref: '#/components/parameters/FileId'
responses:
'200':
description: 文件内容
headers:
Cache-Control: {schema: {type: string, example: no-store}}
Content-Disposition: {schema: {type: string, example: attachment}}
content:
application/octet-stream:
schema: {type: string, format: binary}
/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches:
post:
summary: Firstleg 买单报关明细导出 Provider 契约
description: |
此路径由 Firstleg 提供、File 调用。一柜一份;parameters 只能包含正整数 loadingPlanId。
File 创建该导出任务的唯一 requiredPermission 为 platform:firstleg:customs:manage。
Provider 必须按 schemaVersion=1 返回下列固定顺序列,File 校验后生成 XLSX。
parameters:
- $ref: '#/components/parameters/InternalToken'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ExportBatchRequest'}
responses:
'200':
description: ApiResponse<ExportBatchResponse>
components:
parameters:
InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}}
TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域}
FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}}
LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
BusinessFileResponse:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/BusinessFileMetadata'}
BusinessFileMetadata:
type: object
required: [fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
properties:
fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输}
tenantId: {type: string, description: CUSTOMER owner scope客户ID}
businessType: {type: string, description: fileCategory}
businessId: {type: string, description: loadingPlanId}
fileName: {type: string}
sha256: {type: string}
contentType: {type: string}
sizeBytes: {type: integer, format: int64, maximum: 52428800}
ExportBatchRequest:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
properties:
taskId: {type: string}
tenantId: {type: string, enum: ['0']}
appCode: {type: string, enum: [PLATFORM]}
requestedBy: {type: string}
dispatchVersion: {type: integer}
parameters:
type: object
required: [loadingPlanId]
additionalProperties: false
properties:
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$'}
cursor: {type: string, nullable: true}
snapshotToken: {type: string, nullable: true}
limit: {type: integer, minimum: 50, maximum: 1000}
BuyDeclarationColumns:
type: object
description: 固定列顺序
properties:
customerName: {type: string, title: 客户名称}
orderNo: {type: string, title: 订单号}
inboundCode: {type: string, title: 入仓码}
sku: {type: string, title: SKU}
chineseName: {type: string, title: 中文品名}
englishName: {type: string, title: 英文品名}
cartonCount: {type: integer, title: 箱数}
productQuantity: {type: integer, title: 产品数量}
grossWeight: {type: number, title: 毛重}
netWeight: {type: number, title: 净重}
volume: {type: number, title: 体积}
mark: {type: string, title: 唛头}
customerRemark: {type: string, title: 客户备注}