From b455c0756af0464c3f07e6a34eff40b73191e0bc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 17:20:24 +0800 Subject: [PATCH] Add platform dispatch loading and fuel photo ownership --- .../file/service/BusinessFileCategory.java | 7 + .../fleet-owned-business-photos-v1.yaml | 14 ++ .../service/OwnedBusinessPhotoFileTest.java | 125 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java 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 850060c90..ed10c57fe 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -11,6 +11,8 @@ enum BusinessFileCategory { 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), + OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.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), @@ -71,6 +73,11 @@ enum BusinessFileCategory { /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + // 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。 + if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); + } if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); diff --git a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml new file mode 100644 index 000000000..f1c9fd9f9 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: 自有业务排车运营照片归属契约 + version: 1.0.0 + description: >- + 自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。 + 复用InternalBusinessFileController,不增加公网File路径。 + businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO, + tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。 + 仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 + Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、 + 分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。 + File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java new file mode 100644 index 000000000..d608a51f3 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java @@ -0,0 +1,125 @@ +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.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.file.Path; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OwnedBusinessPhotoFileTest { + private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO"; + private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO"; + @TempDir Path root; + + @BeforeAll static void columns() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class); + } + + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1}); + for (long owner : new long[]{-1, 1, 900}) + assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class); + for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""}) + assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception { + var storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + var category = BusinessFileCategory.require(type); + for (String format : new String[]{"jpeg", "png"}) { + byte[] bytes = image(format); + var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes); + var saved = storage.store(0, "71", upload, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(bytes); + } + for (var upload : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.png", "image/png", new byte[0]), + new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()), + new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())}) + assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class); + MultipartFile oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class); + verify(oversized, never()).getInputStream(); + } + + /** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */ + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + byte[] bytes = image("png"); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length); + row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(bound(query, "status")).isEqualTo("ACTIVE"); + assertThat(bound(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type")) + && "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null; + }); + when(storage.read("owned-photo")).thenReturn(bytes); + assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes); + clearInvocations(storage); + for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING, + "FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) { + assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + private static Object bound(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(?