fix(file): support optional legal ID and order import files

This commit is contained in:
2026-09-01 19:52:42 +08:00
parent 2ec8fcb886
commit b4e496e9cb
13 changed files with 530 additions and 57 deletions
@@ -88,7 +88,7 @@ public class PlatformQualificationFileController {
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@RequestPart("businessLicense") MultipartFile businessLicense,
@RequestPart("legalPersonId") MultipartFile legalPersonId,
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
@RequestParam String unifiedSocialCreditCode,
@RequestParam(required = false) LocalDate businessLicenseValidTo,
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
@@ -79,7 +79,7 @@ public class QualificationFileController {
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("businessLicense") MultipartFile businessLicense,
@RequestPart("legalPersonId") MultipartFile legalPersonId,
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
@RequestParam String unifiedSocialCreditCode,
@RequestParam(required = false) LocalDate businessLicenseValidTo,
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
@@ -7,29 +7,37 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
OCEAN_BOOKING_DOCUMENT(true, true),
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);
TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO);
static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024;
static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024;
static final long ORDER_IMPORT_MAX_SIZE = 5L * 1024 * 1024;
private final boolean customerOwned;
private final boolean arbitraryFormat;
private final FormatPolicy formatPolicy;
private final long maxSizeBytes;
private final BusinessIdPolicy businessIdPolicy;
BusinessFileCategory(boolean customerOwned, boolean arbitraryFormat) {
BusinessFileCategory(boolean customerOwned, FormatPolicy formatPolicy, long maxSizeBytes,
BusinessIdPolicy businessIdPolicy) {
this.customerOwned = customerOwned;
this.arbitraryFormat = arbitraryFormat;
this.formatPolicy = formatPolicy;
this.maxSizeBytes = maxSizeBytes;
this.businessIdPolicy = businessIdPolicy;
}
static BusinessFileCategory require(String value) {
@@ -44,10 +52,35 @@ enum BusinessFileCategory {
}
long maxSizeBytes() {
return arbitraryFormat ? CUSTOMS_MAX_SIZE : STANDARD_MAX_SIZE;
return maxSizeBytes;
}
boolean arbitraryFormat() {
return arbitraryFormat;
FormatPolicy formatPolicy() {
return formatPolicy;
}
String storageBusinessId(String businessId) {
if (businessIdPolicy == BusinessIdPolicy.ORDER_BATCH_NO) {
if (businessId == null || !businessId.matches("OBI-[0-9A-F]{20}")) {
throw new BizException(ResultCode.BAD_REQUEST, "批量导入业务编号格式无效");
}
return businessId;
}
final long value;
try {
value = Long.parseLong(businessId);
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效");
}
if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效");
return Long.toString(value);
}
enum FormatPolicy { STANDARD, ARBITRARY, XLSX }
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024;
private static final long CUSTOMS = 50L * 1024 * 1024;
private static final long ORDER_IMPORT = 5L * 1024 * 1024;
}
}
@@ -22,6 +22,8 @@ import java.util.UUID;
@Component
public class BusinessFileStorage {
private static final String XLSX_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png");
@@ -36,12 +38,32 @@ public class BusinessFileStorage {
}
Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) {
return store(tenantId, Long.toString(businessId), file, maxSize,
arbitraryFormat ? BusinessFileCategory.FormatPolicy.ARBITRARY : BusinessFileCategory.FormatPolicy.STANDARD);
}
Stored store(long tenantId, String businessId, MultipartFile file, BusinessFileCategory category) {
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
}
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
BusinessFileCategory.FormatPolicy formatPolicy) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
if (businessId == null || !businessId.matches("[A-Za-z0-9-]+")) throw bad("业务ID格式无效");
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 extension;
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
extension = "";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.XLSX) {
if (!XLSX_TYPE.equals(type)) throw bad("附件仅支持XLSX");
verifyXlsx(file);
extension = ".xlsx";
} else {
extension = TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type);
}
String name = safeName(file);
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
@@ -55,6 +77,18 @@ public class BusinessFileStorage {
}
}
private static void verifyXlsx(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4);
if (prefix.length < 4 || prefix[0] != 0x50 || prefix[1] != 0x4b
|| prefix[2] != 0x03 || prefix[3] != 0x04) {
throw bad("上传内容不是有效XLSX");
}
} catch (IOException exception) {
throw bad("附件内容读取失败");
}
}
private static String safeContentType(String contentType) {
if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE;
try {
@@ -22,8 +22,8 @@ public class InternalBusinessFileService {
}
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
long parsedBusinessId = parseBusinessId(businessId);
var stored = storage.store(tenantId, parsedBusinessId, file, category.maxSizeBytes(), category.arbitraryFormat());
String storageBusinessId = category.storageBusinessId(businessId);
var stored = storage.store(tenantId, storageBusinessId, file, category);
FileObjectEntity value = new FileObjectEntity();
value.setTenantId(tenantId);
value.setBusinessType(businessType);
@@ -78,18 +78,7 @@ public class InternalBusinessFileService {
}
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
parseBusinessId(businessId);
}
private static long parseBusinessId(String businessId) {
final long value;
try {
value = Long.parseLong(businessId);
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效");
}
if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效");
return value;
category.storageBusinessId(businessId);
}
private static Metadata metadata(FileObjectEntity value) {
@@ -28,18 +28,25 @@ public class QualificationPackageService {
if (!normalizedCreditCode.matches("[0-9A-Z]{18}")) {
throw new BizException(ResultCode.BAD_REQUEST, "请输入有效的18位统一社会信用代码");
}
if (businessLicense == null || businessLicense.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "营业执照文件不能为空");
}
if (legalPersonId == null && (legalPersonIdValidTo != null || replaceLegalPersonId)) {
throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能填写其有效期或替换标记");
}
LocalQualificationStorage.StoredFile storedBusinessLicense = storage.store(tenantId, businessLicense);
LocalQualificationStorage.StoredFile storedLegalPersonId;
LocalQualificationStorage.StoredFile storedLegalPersonId = null;
try {
storedLegalPersonId = storage.store(tenantId, legalPersonId);
if (legalPersonId != null) storedLegalPersonId = storage.store(tenantId, legalPersonId);
} catch (RuntimeException exception) {
storage.delete(storedBusinessLicense);
throw exception;
}
String legalPersonIdDocumentUrl = storedLegalPersonId == null ? null : storedLegalPersonId.documentUrl();
var request = new IamQualificationClient.RequiredPackageRequest(
tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicenseValidTo,
replaceBusinessLicense, storedLegalPersonId.documentUrl(), legalPersonIdValidTo, replaceLegalPersonId);
replaceBusinessLicense, legalPersonIdDocumentUrl, legalPersonIdValidTo, replaceLegalPersonId);
try {
var response = platform
? iamClient.submitPlatformRequiredPackage(internalToken, request)
@@ -50,7 +57,7 @@ public class QualificationPackageService {
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
storage.delete(storedBusinessLicense);
storage.delete(storedLegalPersonId);
deleteIfStored(storedLegalPersonId);
throw new BizException(code, message);
}
if (response.data() == null) {
@@ -58,20 +65,24 @@ public class QualificationPackageService {
}
var data = response.data();
deleteReplacedFile(tenantId, data.replacedBusinessLicenseDocumentUrl(), storedBusinessLicense.documentUrl());
deleteReplacedFile(tenantId, data.replacedLegalPersonIdDocumentUrl(), storedLegalPersonId.documentUrl());
deleteReplacedFile(tenantId, data.replacedLegalPersonIdDocumentUrl(), legalPersonIdDocumentUrl);
return new PackageUploadResult(data.businessLicenseQualificationId(), data.legalPersonIdQualificationId(),
data.tenantStatus(), storedBusinessLicense.documentUrl(), storedLegalPersonId.documentUrl());
data.tenantStatus(), storedBusinessLicense.documentUrl(), legalPersonIdDocumentUrl);
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
LOGGER.error("IAM 必填资质包登记结果未知,保留已写入文件等待重试或对账,tenantId={}, businessLicenseUrl={}, legalPersonIdUrl={}",
tenantId, storedBusinessLicense.documentUrl(), storedLegalPersonId.documentUrl(), exception);
tenantId, storedBusinessLicense.documentUrl(), legalPersonIdDocumentUrl, exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
}
}
private void deleteReplacedFile(long tenantId, String replacedUrl, String currentUrl) {
if (!currentUrl.equals(replacedUrl)) storage.deleteByDocumentUrl(tenantId, replacedUrl);
if (currentUrl != null && !currentUrl.equals(replacedUrl)) storage.deleteByDocumentUrl(tenantId, replacedUrl);
}
private void deleteIfStored(LocalQualificationStorage.StoredFile storedFile) {
if (storedFile != null) storage.delete(storedFile);
}
public record PackageUploadResult(Long businessLicenseQualificationId,