feat(firstleg): implement container customs workflow
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user