From b4e496e9cb3da7345acc26d3834e57b2c3efd51a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 19:52:42 +0800 Subject: [PATCH] fix(file): support optional legal ID and order import files --- .../PlatformQualificationFileController.java | 2 +- .../QualificationFileController.java | 2 +- .../file/service/BusinessFileCategory.java | 71 ++++++++--- .../file/service/BusinessFileStorage.java | 40 +++++- .../service/InternalBusinessFileService.java | 17 +-- .../service/QualificationPackageService.java | 27 ++-- .../openapi/order-batch-import-file-v1.yaml | 118 ++++++++++++++++++ .../qualification-required-package-v1.yaml | 113 +++++++++++++++++ ...cationAndOrderFileOpenApiContractTest.java | 37 ++++++ .../QualificationFileContractTest.java | 19 +++ .../file/service/BusinessFileStorageTest.java | 30 +++++ .../InternalBusinessFileServiceTest.java | 76 +++++++++-- .../QualificationPackageServiceTest.java | 35 ++++++ 13 files changed, 530 insertions(+), 57 deletions(-) create mode 100644 file/src/main/resources/openapi/order-batch-import-file-v1.yaml create mode 100644 file/src/main/resources/openapi/qualification-required-package-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 46d5fb0a6..e301f5f94 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -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, diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index f55542e6c..058abedca 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -79,7 +79,7 @@ public class QualificationFileController { public ApiResponse 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, diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index fdb59dbb7..697269244 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -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; } } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index e0a8b2c87..da478ec75 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -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 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 { diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 779a7acde..1aa975c21 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -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) { diff --git a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java index 9c26fecab..63d2c00aa 100644 --- a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java +++ b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java @@ -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, diff --git a/file/src/main/resources/openapi/order-batch-import-file-v1.yaml b/file/src/main/resources/openapi/order-batch-import-file-v1.yaml new file mode 100644 index 000000000..816004a54 --- /dev/null +++ b/file/src/main/resources/openapi/order-batch-import-file-v1.yaml @@ -0,0 +1,118 @@ +openapi: 3.0.3 +info: + title: Order Batch Import File Internal API + version: 1.0.0 + description: >- + Order批量导入源文件与错误报告均为客户所有的不可变XLSX对象;businessId固定为Order生成的batchNo。 + File只保存并校验对象归属,不复制导入任务状态。 + x-lifecycle-owner: ORDER + x-file-object-semantics: IMMUTABLE_CREATE_ONLY +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}: + post: + summary: Order上传批量导入源文件或错误报告 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/OperatorId'} + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BatchNo'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 有效XLSX文件,最大5MiB + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}/{fileId}: + get: + summary: Order精确校验批量导入文件元数据 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BatchNo'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}/{fileId}/content: + get: + summary: Order下载批量导入源文件或错误报告 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BatchNo'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': + description: 精确归属的XLSX附件;错误客户、类别、batchNo或fileId不得命中 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}} + content: + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: + schema: {type: string, format: binary} +components: + securitySchemes: + internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + OperatorId: + name: X-Operator-Id + in: header + required: true + schema: {$ref: '#/components/schemas/Id'} + TenantId: + name: tenantId + in: path + required: true + description: 当前客户ID,必须大于0 + schema: {$ref: '#/components/schemas/Id'} + BusinessType: + name: businessType + in: path + required: true + schema: {type: string, enum: [ORDER_BATCH_IMPORT_SOURCE, ORDER_BATCH_IMPORT_ERROR_REPORT]} + BatchNo: + name: batchNo + in: path + required: true + schema: {type: string, pattern: '^OBI-[0-9A-F]{20}$'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + responses: + Metadata: + description: 客户批量导入文件元数据 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + data: {$ref: '#/components/schemas/Metadata'} + schemas: + Id: {type: string, pattern: '^[1-9][0-9]*$'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {$ref: '#/components/schemas/Id'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {$ref: '#/components/schemas/Id'} + businessType: {type: string, enum: [ORDER_BATCH_IMPORT_SOURCE, ORDER_BATCH_IMPORT_ERROR_REPORT]} + businessId: {type: string, pattern: '^OBI-[0-9A-F]{20}$'} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880} diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml new file mode 100644 index 000000000..482a0e339 --- /dev/null +++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml @@ -0,0 +1,113 @@ +openapi: 3.0.3 +info: + title: Qualification Required Package API + version: 1.0.0 + description: 营业执照为必填资质;法人身份证为选填资质,缺省时不创建、不替换法人身份证记录。 +paths: + /api/merchant/v1/files/qualifications/required-package: + post: + summary: 客户上传必需资质包 + description: 需要 merchant:qualification:upload;营业执照必填,法人身份证选填。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/UnifiedSocialCreditCode'} + - {$ref: '#/components/parameters/BusinessLicenseValidTo'} + - {$ref: '#/components/parameters/LegalPersonIdValidTo'} + - {$ref: '#/components/parameters/ReplaceBusinessLicense'} + - {$ref: '#/components/parameters/ReplaceLegalPersonId'} + requestBody: {$ref: '#/components/requestBodies/RequiredPackage'} + responses: + '200': {$ref: '#/components/responses/PackageResult'} + /api/platform/v1/files/qualifications/{tenantId}/required-package: + post: + summary: 平台为客户上传必需资质包 + description: 需要 platform:customer:create 或 platform:customer:edit;营业执照必填,法人身份证选填。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/UnifiedSocialCreditCode'} + - {$ref: '#/components/parameters/BusinessLicenseValidTo'} + - {$ref: '#/components/parameters/LegalPersonIdValidTo'} + - {$ref: '#/components/parameters/ReplaceBusinessLicense'} + - {$ref: '#/components/parameters/ReplaceLegalPersonId'} + requestBody: {$ref: '#/components/requestBodies/RequiredPackage'} + responses: + '200': {$ref: '#/components/responses/PackageResult'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + UnifiedSocialCreditCode: + name: unifiedSocialCreditCode + in: query + required: true + schema: {type: string, pattern: '^[0-9A-Za-z]{18}$'} + BusinessLicenseValidTo: + name: businessLicenseValidTo + in: query + required: false + schema: {type: string, format: date} + LegalPersonIdValidTo: + name: legalPersonIdValidTo + in: query + required: false + description: 仅上传legalPersonId时允许填写 + schema: {type: string, format: date} + ReplaceBusinessLicense: + name: replaceBusinessLicense + in: query + required: false + schema: {type: boolean, default: false} + ReplaceLegalPersonId: + name: replaceLegalPersonId + in: query + required: false + description: 未上传legalPersonId时必须为false + schema: {type: boolean, default: false} + requestBodies: + RequiredPackage: + required: true + content: + multipart/form-data: + schema: + type: object + required: [businessLicense] + properties: + businessLicense: + type: string + format: binary + description: 营业执照,必填;PDF/JPG/PNG,最大5MB + legalPersonId: + type: string + format: binary + nullable: true + description: 法人身份证,选填;PDF/JPG/PNG,最大5MB + responses: + PackageResult: + description: 资质包上传结果 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + data: {$ref: '#/components/schemas/PackageUploadResult'} + schemas: + Id: {type: string, pattern: '^[1-9][0-9]*$'} + PackageUploadResult: + type: object + required: [businessLicenseQualificationId, tenantStatus, businessLicenseDocumentUrl] + properties: + businessLicenseQualificationId: {$ref: '#/components/schemas/Id'} + legalPersonIdQualificationId: + allOf: [{$ref: '#/components/schemas/Id'}] + nullable: true + tenantStatus: {type: string} + businessLicenseDocumentUrl: {type: string, minLength: 1} + legalPersonIdDocumentUrl: {type: string, nullable: true} diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java new file mode 100644 index 000000000..dca674082 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -0,0 +1,37 @@ +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 QualificationAndOrderFileOpenApiContractTest { + @Test + void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception { + String yaml = Files.readString(Path.of( + "src/main/resources/openapi/qualification-required-package-v1.yaml")); + + assertThat(yaml) + .contains("required: [businessLicense]") + .contains("legalPersonId:", "法人身份证,选填") + .contains("legalPersonIdQualificationId:", "legalPersonIdDocumentUrl:") + .doesNotContain("required: [businessLicense, legalPersonId]"); + } + + @Test + void orderBatchCategoriesAreCustomerOwnedXlsxObjectsWithStableBatchNumber() throws Exception { + String yaml = Files.readString(Path.of( + "src/main/resources/openapi/order-batch-import-file-v1.yaml")); + + assertThat(yaml) + .contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT") + .contains("ownerScope: {type: string, enum: [CUSTOMER]}") + .contains("pattern: '^OBI-[0-9A-F]{20}$'") + .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .contains("maximum: 5242880") + .contains("Cache-Control", "no-store") + .doesNotContain("ORDER_IMPORT_SOURCE", "ORDER_IMPORT_ERROR"); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 719b3402d..773d0eefd 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -18,6 +18,7 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import java.nio.file.Path; import java.time.LocalDate; @@ -81,6 +82,12 @@ class QualificationFileContractTest { "/{tenantId}/required-package")); } + @Test + void requiredPackageKeepsBusinessLicenseRequiredAndMakesLegalPersonIdOptional() { + assertRequiredPackageParts(QualificationFileController.class); + assertRequiredPackageParts(PlatformQualificationFileController.class); + } + @Test void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception { var method = PlatformQualificationFileController.class.getDeclaredMethod( @@ -301,6 +308,18 @@ class QualificationFileContractTest { && Arrays.asList(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE)); } + private static void assertRequiredPackageParts(Class controllerType) { + var method = Arrays.stream(controllerType.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("uploadRequiredPackage")) + .findFirst().orElseThrow(); + var parts = Arrays.stream(method.getParameters()) + .map(parameter -> parameter.getAnnotation(RequestPart.class)) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toMap(RequestPart::value, RequestPart::required)); + assertEquals(Boolean.TRUE, parts.get("businessLicense")); + assertEquals(Boolean.FALSE, parts.get("legalPersonId")); + } + private QualificationFileController merchantController( LocalQualificationStorage storage, IamQualificationClient iamClient) { var controller = merchantController; diff --git a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java index 390584ecf..a08d53383 100644 --- a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java @@ -86,6 +86,36 @@ class BusinessFileStorageTest { .isInstanceOf(BizException.class).hasMessageContaining("仅支持"); } + @Test void orderBatchCategoriesAcceptOnlyRealXlsxWithinFiveMegabytes() { + BusinessFileStorage storage = storage(); + var xlsx = new MockMultipartFile("file", "orders.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x50, 0x4b, 0x03, 0x04, 1, 2}); + + var stored = storage.store(10001L, "OBI-0123456789ABCDEF0123", xlsx, + BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE); + + assertThat(stored.objectKey()).endsWith(".xlsx"); + assertThat(stored.contentType()).isEqualTo(xlsx.getContentType()); + + var wrongContent = new MockMultipartFile("file", "orders.xlsx", "application/octet-stream", + new byte[]{0x50, 0x4b, 0x03, 0x04}); + assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", wrongContent, + BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持XLSX"); + + var fakeXlsx = new MockMultipartFile("file", "orders.xlsx", xlsx.getContentType(), new byte[]{1, 2, 3, 4}); + assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", fakeXlsx, + BusinessFileCategory.ORDER_BATCH_IMPORT_ERROR_REPORT)) + .isInstanceOf(BizException.class).hasMessageContaining("不是有效XLSX"); + + MultipartFile tooLarge = new SparseMultipartFile("orders.xlsx", xlsx.getContentType(), + BusinessFileCategory.ORDER_IMPORT_MAX_SIZE + 1); + assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", tooLarge, + BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE)) + .isInstanceOf(BizException.class).hasMessageContaining("5MB"); + } + private BusinessFileStorage storage() { BusinessFileStorage storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 91cef3a2e..432e381de 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -25,7 +25,7 @@ class InternalBusinessFileServiceTest { @Test void customsDocumentUploadPersistsExactBusinessOwnership() { MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes()); - when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored( + when(storage.store(10001L, "41", file, BusinessFileCategory.CUSTOMS_DOCUMENT)).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; @@ -41,10 +41,10 @@ class InternalBusinessFileServiceTest { @Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() { MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes()); - when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation -> + when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); - when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored( + when(storage.store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).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; @@ -68,15 +68,17 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessId) .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); - 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); + verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_CLEARANCE_DOCUMENT); + verify(storage).store(0L, "51", file, BusinessFileCategory.LOADING_CUSTOMS_DECLARATION); + verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_DELIVERY_ORDER); + verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER); + verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); } @Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() { MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes()); - when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", file.getSize(), "f".repeat(64), "booking.xlsx")); @@ -126,7 +128,7 @@ class InternalBusinessFileServiceTest { @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); - when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored( + when(storage.store(100L, "81", file, BusinessFileCategory.OVERSEAS_RELABEL_PHOTO)).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; @@ -142,7 +144,7 @@ class InternalBusinessFileServiceTest { @Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() { MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes()); - when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class))) .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 -> { @@ -157,7 +159,8 @@ class InternalBusinessFileServiceTest { 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); + verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE); + verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT); } @Test void loadingCustomsCategoriesRejectPlatformOwnerScope() { @@ -177,6 +180,57 @@ class InternalBusinessFileServiceTest { verifyNoInteractions(files, storage); } + @Test void orderBatchSourceAndErrorReportUseCustomerAndBatchNumberOwnership() { + String batchNo = "OBI-0123456789ABCDEF0123"; + var file = new MockMultipartFile("file", "orders.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x50, 0x4b, 0x03, 0x04, 1}); + when(storage.store(eq(10001L), eq(batchNo), same(file), any(BusinessFileCategory.class))) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/" + batchNo + "/object.xlsx", + file.getContentType(), file.getSize(), "9".repeat(64), file.getOriginalFilename())); + AtomicLong ids = new AtomicLong(300); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet()); + return 1; + }); + + var source = service.upload(10001L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 7L, file); + var error = service.upload(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 7L, file); + + assertThat(source).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessId) + .containsExactly("CUSTOMER", 10001L, batchNo); + assertThat(error.businessType()).isEqualTo("ORDER_BATCH_IMPORT_ERROR_REPORT"); + verify(storage).store(10001L, batchNo, file, BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE); + verify(storage).store(10001L, batchNo, file, BusinessFileCategory.ORDER_BATCH_IMPORT_ERROR_REPORT); + } + + @Test void orderBatchFilesRejectPlatformOwnerMalformedBatchAndWrongCompositeOwnership() { + String batchNo = "OBI-0123456789ABCDEF0123"; + var file = new MockMultipartFile("file", "orders.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + new byte[]{0x50, 0x4b, 0x03, 0x04}); + assertThatThrownBy(() -> service.upload(0L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + assertThatThrownBy(() -> service.upload(10001L, "ORDER_BATCH_IMPORT_SOURCE", "41", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("批量导入业务编号"); + + FileObjectEntity value = new FileObjectEntity(); value.setId(401L); value.setTenantId(10001L); + value.setBusinessType("ORDER_BATCH_IMPORT_SOURCE"); value.setBusinessId(batchNo); + value.setOriginalName("orders.xlsx"); value.setObjectKey("business-files/10001/" + batchNo + "/object.xlsx"); + value.setContentType(file.getContentType()); value.setSha256("8".repeat(64)); value.setSizeBytes(file.getSize()); + when(files.selectOne(any())).thenReturn(value, null, null, null); + + assertThat(service.metadata(10001L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 401L).fileId()).isEqualTo(401L); + assertThatThrownBy(() -> service.metadata(10002L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 401L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 401L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "ORDER_BATCH_IMPORT_SOURCE", + "OBI-FFFFFFFFFFFFFFFFFFFF", 401L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + @Test void downloadUsesValidatedMetadataObjectKey() { FileObjectEntity value = new FileObjectEntity(); value.setId(71L); value.setTenantId(10001L); value.setBusinessType("CUSTOMS_RECEIPT"); value.setBusinessId("61"); value.setOriginalName("release.txt"); @@ -215,7 +269,7 @@ class InternalBusinessFileServiceTest { @Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "booking.bin", "application/x-booking", new byte[]{1, 2, 3}); - when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object", "application/x-booking", 3, "a".repeat(64), "booking.bin")); AtomicLong ids = new AtomicLong(200); diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java index 3d8cb46b7..2157f6b76 100644 --- a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java @@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import jakarta.annotation.Resource; import java.nio.file.Path; +import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -59,6 +61,39 @@ class QualificationPackageServiceTest { assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl()); } + @Test + void legalPersonIdMayBeOmittedWithoutRelaxingTheBusinessLicense() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.RequiredPackageResponse(31L, null, "PROFILE_REQUIRED", null, null))); + + var result = fixture.service.upload(TENANT_ID, fixture.businessLicense, null, + "91310101abcde12345", null, null, false, false, false); + + var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class); + verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture()); + assertNull(captor.getValue().legalPersonIdDocumentUrl()); + assertNull(captor.getValue().legalPersonIdValidTo()); + assertNull(result.legalPersonIdQualificationId()); + assertNull(result.legalPersonIdDocumentUrl()); + verify(fixture.storage, never()).store(TENANT_ID, fixture.legalPersonId); + } + + @Test + void missingBusinessLicenseAndOrphanedLegalPersonParametersStillFailClosed() { + var fixture = fixture(); + + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, null, null, + "91310101ABCDE12345", null, null, false, false, false)); + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, fixture.businessLicense, null, + "91310101ABCDE12345", null, LocalDate.of(2030, 1, 1), false, false, false)); + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, fixture.businessLicense, null, + "91310101ABCDE12345", null, null, false, true, false)); + + verify(fixture.iamClient, never()).submitRequiredPackage(any(), any()); + } + @Test void secondStorageFailureDeletesTheFirstFileAndNeverCallsIam() { var fixture = fixture();