feat(firstleg): implement container customs workflow
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
| 平台订单商品图片读取 | `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` |
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `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` |
|
||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||
@@ -27,6 +28,7 @@
|
||||
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
|
||||
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
|
||||
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
|
||||
- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。
|
||||
|
||||
## 全量验收导航
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: 客户备注}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 CustomsFileExportContractTest {
|
||||
private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||
|
||||
@Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception {
|
||||
for (String file : new String[]{
|
||||
"file/src/main/resources/application.yml",
|
||||
"config/nacos/local/file-service.yml",
|
||||
"config/nacos/cn/file-service.yml"}) {
|
||||
String source = Files.readString(repositoryRoot().resolve(file));
|
||||
assertThat(source).contains(TYPE + ":")
|
||||
.contains("provider-service: firstleg-service")
|
||||
.contains("provider-path: /internal/exports/v1/" + TYPE + "/batches")
|
||||
.contains("required-permission: platform:firstleg:customs:manage")
|
||||
.contains("app-scope: PLATFORM")
|
||||
.doesNotContain("loading-list:export");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void openApiFreezesOwnerScopeSizeVisibilityAndColumns() throws Exception {
|
||||
String source = Files.readString(repositoryRoot()
|
||||
.resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml"));
|
||||
|
||||
assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT")
|
||||
.contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId")
|
||||
.contains("platform:firstleg:customs:manage", "platform:firstleg:loading-list:list",
|
||||
"merchant:order:edit", "merchant:order:view")
|
||||
.contains("customerName", "orderNo", "inboundCode", "sku", "chineseName", "englishName",
|
||||
"cartonCount", "productQuantity", "grossWeight", "netWeight", "volume", "mark",
|
||||
"customerRemark")
|
||||
.doesNotContain("productNameZh", "productNameEn", "shippingMark", "loading-list:export");
|
||||
}
|
||||
|
||||
private static Path repositoryRoot() {
|
||||
Path current = Path.of("").toAbsolutePath();
|
||||
return Files.isDirectory(current.resolve("file")) ? current : current.getParent();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,18 @@ class InternalBusinessFileControllerTest {
|
||||
verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L);
|
||||
}
|
||||
|
||||
@Test void arbitraryResultDownloadUsesAttachmentAndFallsBackToBinaryContentType() {
|
||||
when(service.download(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 92L))
|
||||
.thenReturn(new InternalBusinessFileService.Download("完成文件.unknown", "invalid content type", new byte[]{3}));
|
||||
|
||||
var response = controller.download("internal-token", 10001L,
|
||||
"LOADING_LIST_CUSTOMS_RESULT", "51", 92L);
|
||||
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
|
||||
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
|
||||
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
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.when;
|
||||
|
||||
class BusinessFileStorageTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "folder/manifest.custom", "application/x-custom", new byte[]{1, 2, 3});
|
||||
|
||||
var stored = storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.originalName()).isEqualTo("manifest.custom");
|
||||
assertThat(stored.contentType()).isEqualTo("application/x-custom");
|
||||
assertThat(storage.read(stored.objectKey())).containsExactly(1, 2, 3);
|
||||
}
|
||||
|
||||
@Test void customsFileAboveFiftyMegabytesIsRejectedBeforeRead() {
|
||||
BusinessFileStorage storage = storage();
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(BusinessFileCategory.CUSTOMS_MAX_SIZE + 1);
|
||||
|
||||
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("50MB");
|
||||
}
|
||||
|
||||
@Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
|
||||
BusinessFileCategory.STANDARD_MAX_SIZE, false))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
BusinessFileStorage storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,34 @@ class ExportTaskServiceTest {
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buyDeclarationExportRequiresOnePositiveLoadingPlanId() {
|
||||
var definition = definition();
|
||||
definition.setProviderService("firstleg-service");
|
||||
definition.setProviderPath("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches");
|
||||
definition.setRequiredPermission("platform:firstleg:customs:manage");
|
||||
when(registry.require("LOADING_LIST_BUY_DECLARATION_ITEMS")).thenReturn(definition);
|
||||
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
|
||||
new ExportStateService.CreationResult(invocation.getArgument(0), true));
|
||||
var user = user(1L, Set.of("platform:firstleg:customs:manage"));
|
||||
|
||||
var response = service.create(user, new CreateExportTaskRequest(
|
||||
"LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of("loadingPlanId", "51")));
|
||||
|
||||
assertEquals("LOADING_LIST_BUY_DECLARATION_ITEMS", response.exportType());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of())))
|
||||
.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
|
||||
Map.of("loadingPlanId", 0))))
|
||||
.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
|
||||
Map.of("loadingPlanId", 51L))))
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(long id, Set<String> permissions) {
|
||||
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,56 @@ class GenericXlsxExportRunnerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void buyDeclarationProviderMustReturnTheFrozenThirteenColumnsInOrder() {
|
||||
List<ExportColumn> invalid = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
|
||||
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
|
||||
invalid = new java.util.ArrayList<>(invalid);
|
||||
invalid.set(4, new ExportColumn("productNameZh", "中文品名", ExportColumnType.STRING, 18));
|
||||
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
|
||||
invalid, List.of(), null, false, 0L);
|
||||
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
|
||||
.thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task = new FileExportTask(); task.setId(51L); task.setTenantId(0L);
|
||||
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
|
||||
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
|
||||
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
|
||||
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
|
||||
|
||||
assertThrows(ExportFailure.class,
|
||||
() -> runner.run(task, definition, new AtomicBoolean(), ignored -> { }));
|
||||
}
|
||||
|
||||
@Test void frozenBuyDeclarationProviderColumnsGenerateWorkbookDirectly() throws Exception {
|
||||
List<ExportColumn> columns = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
|
||||
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
|
||||
Map<String, Object> row = new java.util.LinkedHashMap<>();
|
||||
row.put("customerName", "客户A"); row.put("orderNo", "O-1"); row.put("inboundCode", "IN-1");
|
||||
row.put("sku", "SKU-1"); row.put("chineseName", "中文品名"); row.put("englishName", "English name");
|
||||
row.put("cartonCount", 2); row.put("productQuantity", 20); row.put("grossWeight", "12.5");
|
||||
row.put("netWeight", "11.5"); row.put("volume", "1.25"); row.put("mark", "MARK-1");
|
||||
row.put("customerRemark", "备注");
|
||||
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
|
||||
columns, List.of(row), null, false, 1L);
|
||||
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
|
||||
.thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task = new FileExportTask(); task.setId(52L); task.setTenantId(0L);
|
||||
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
|
||||
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
|
||||
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
|
||||
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
|
||||
|
||||
var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { });
|
||||
|
||||
assertEquals(1L, result.rows());
|
||||
try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
|
||||
var sheet = workbook.getSheetAt(0);
|
||||
assertEquals("客户名称", sheet.getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("客户A", sheet.getRow(1).getCell(0).getStringCellValue());
|
||||
assertEquals("中文品名", sheet.getRow(0).getCell(4).getStringCellValue());
|
||||
assertEquals("唛头", sheet.getRow(0).getCell(11).getStringCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestConfig {
|
||||
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
|
||||
|
||||
@@ -21,7 +21,7 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
|
||||
when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
|
||||
@@ -37,10 +37,10 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation ->
|
||||
when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation ->
|
||||
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
|
||||
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
|
||||
when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
|
||||
@@ -64,9 +64,9 @@ class InternalBusinessFileServiceTest {
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
|
||||
verify(storage, times(3)).storeLoading(0L, 41L, file);
|
||||
verify(storage).storeLoading(0L, 51L, file);
|
||||
verify(storage).storeLoading(11L, 51L, file);
|
||||
verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
}
|
||||
|
||||
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
|
||||
@@ -82,7 +82,7 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
|
||||
when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
|
||||
@@ -96,6 +96,36 @@ class InternalBusinessFileServiceTest {
|
||||
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
|
||||
}
|
||||
|
||||
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
|
||||
when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
|
||||
file.getSize(), "e".repeat(64), "资料包.zip"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(101L); return 1;
|
||||
});
|
||||
|
||||
var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file);
|
||||
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
|
||||
|
||||
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
|
||||
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
|
||||
verify(storage, times(2)).store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
}
|
||||
|
||||
@Test void loadingCustomsCategoriesRejectPlatformOwnerScope() {
|
||||
var file = new MockMultipartFile("file", "source.bin", "application/octet-stream", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
@Test void unsupportedBusinessTypeFailsClosed() {
|
||||
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
|
||||
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
|
||||
|
||||
Reference in New Issue
Block a user