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 52f3dc3ba..850060c90 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -9,6 +9,8 @@ import java.util.Arrays; enum BusinessFileCategory { FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -67,7 +69,12 @@ enum BusinessFileCategory { .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型")); } + /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); + } if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); } diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml new file mode 100644 index 000000000..e384ffb41 --- /dev/null +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -0,0 +1,167 @@ +openapi: 3.0.3 +info: + title: 平台排车车辆与司机证据内部文件契约 + version: 1.0.0 + description: >- + 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 + 两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, + 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。 + File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 + PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId + 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 + 上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。 + Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。 + 本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。 + 统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + post: + operationId: uploadDispatchEvidence + summary: 上传本排车的车辆或司机证据 + parameters: + - name: X-Operator-Id + in: header + required: true + description: Lastleg 已认证操作者的正数 Long ID + schema: {$ref: '#/components/schemas/PositiveLongString'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + operationId: readDispatchEvidenceMetadata + summary: 按本排车与证据类别联合校验并读取元数据 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + operationId: downloadDispatchEvidence + summary: 按完整复合归属读取文件内容 + responses: + '200': + description: >- + 成功返回真实文件;错误返回 application/json 统一业务错误。 + A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效, + A0404 表示文件不存在、非有效文件或复合归属不匹配。 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名} + Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数} + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + TenantId: + name: tenantId + in: path + required: true + description: 两类排车证据只属于平台,客户作用域一律拒绝 + schema: {type: string, enum: ['0']} + BusinessType: + name: businessType + in: path + required: true + schema: {$ref: '#/components/schemas/DispatchEvidenceType'} + BusinessId: + name: businessId + in: path + required: true + description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入 + schema: {$ref: '#/components/schemas/PositiveLongString'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/PositiveLongString'} + responses: + MetadataOrError: + description: >- + 统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件; + A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配; + S0503为存储不可用,S0500为元数据保存失败等内部异常。 + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/MetadataEnvelope'} + - {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + PositiveLongString: + type: string + pattern: '^[1-9][0-9]*$' + maxLength: 19 + description: 正数Long的十进制字符串,值不得超过9223372036854775807 + DispatchEvidenceType: + type: string + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE] + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + message: {type: string} + data: {$ref: '#/components/schemas/Metadata'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt] + properties: + fileId: {$ref: '#/components/schemas/PositiveLongString'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: {type: string, enum: ['0']} + businessType: {$ref: '#/components/schemas/DispatchEvidenceType'} + businessId: {$ref: '#/components/schemas/PositiveLongString'} + fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]} + sizeBytes: + type: string + pattern: '^[1-9][0-9]*$' + description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串 + example: '1024' + createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移} + ErrorEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]} + message: {type: string} + data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java new file mode 100644 index 000000000..2444e080c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -0,0 +1,245 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.controller.InternalBusinessFileController; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DispatchEvidenceFileTest { + private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; + private static final int MAX_BYTES = 10 * 1024 * 1024; + @TempDir Path root; + + /** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */ + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"), + FileObjectEntity.class); + } + + /** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var upload = pdf(); + for (long customer : new long[]{-1, 1, 900}) { + assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class); + } + for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) { + assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + /** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + for (var file : new MockMultipartFile[]{pdf(), + new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")), + new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) { + var saved = storage.store(0, "71", file, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + assertThat(saved.contentType()).isEqualTo(file.getContentType()); + assertThat(saved.size()).isEqualTo(file.getSize()); + assertThat(saved.sha256()).matches("[0-9a-f]{64}"); + } + for (var file : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), + new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)), + new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")), + new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")), + new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()), + new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()), + new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) { + assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class); + } + + /** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES); + var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content); + var stored = storage.store(0, "71", boundary, category); + assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES); + var oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized, never()).getInputStream(); + verify(oversized, never()).transferTo(any(Path.class)); + } + + /** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = storage(); + var service = service(files, storage); + var ids = new AtomicLong(500); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + FileObjectEntity row = call.getArgument(0); + row.setId(ids.incrementAndGet()); + return 1; + }); + var first = service.upload(0, type, "71", 7, pdf()); + var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf", + "%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII)); + var second = service.upload(0, type, "71", 7, secondFile); + var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files, times(2)).insert(rows.capture()); + assertThat(first.ownerScope()).isEqualTo("PLATFORM"); + assertThat(first.tenantId()).isZero(); + assertThat(first.businessType()).isEqualTo(type); + assertThat(first.businessId()).isEqualTo("71"); + assertThat(first.fileId()).isEqualTo(501L); + assertThat(second.fileId()).isEqualTo(502L); + assertThat(first.fileName()).isEqualTo("evidence.pdf"); + assertThat(first.contentType()).isEqualTo("application/pdf"); + assertThat(first.sizeBytes()).isEqualTo(pdf().getSize()); + assertThat(first.sha256()).matches("[0-9a-f]{64}"); + assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt()); + assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey()); + assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes()); + assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes()); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } + + /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf"); + row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize()); + row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(boundValue(query, "status")).isEqualTo("ACTIVE"); + assertThat(boundValue(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(boundValue(query, "tenant_id")) + && type.equals(boundValue(query, "business_type")) + && "71".equals(boundValue(query, "business_id")) + && Long.valueOf(501).equals(boundValue(query, "id")) ? row : null; + }); + when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes()); + var metadata = service.metadata(0, type, "71", 501); + assertThat(metadata.fileId()).isEqualTo(501L); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes()); + verify(storage).read(row.getObjectKey()); + clearInvocations(storage); + String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE; + for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) { + assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.download(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + /** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) { + var service = mock(InternalBusinessFileService.class); + var controller = new InternalBusinessFileController(); + ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token"); + ReflectionTestUtils.setField(controller, "service", service); + for (String token : new String[]{null, "", "wrong-token"}) { + assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + } + verifyNoInteractions(service); + } + + /** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */ + private static Object boundValue(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(?