feat(fleet): attach scoped evidence to measured trip records

This commit is contained in:
2026-10-03 16:38:37 +08:00
parent d05ff1cd26
commit e49ad96cc4
22 changed files with 584 additions and 27 deletions
@@ -7,7 +7,8 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
FLEET_DRIVER_LICENSE(false, FormatPolicy.LICENSE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
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),
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),
@@ -62,6 +63,9 @@ enum BusinessFileCategory {
}
void validateOwner(long tenantId) {
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
}
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
}
@@ -105,7 +109,7 @@ enum BusinessFileCategory {
return Long.toString(value);
}
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, LICENSE, IMAGE }
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE }
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024;
@@ -66,8 +66,8 @@ public class BusinessFileStorage {
} else {
if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type))
throw bad("图片附件仅支持JPG、PNG");
if (formatPolicy == BusinessFileCategory.FormatPolicy.LICENSE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type))
throw bad("驾驶证附件仅支持PDF、JPG、PNG");
if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF_IMAGE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type))
throw bad("附件仅支持PDF、JPG、PNG");
extension = TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type);
@@ -0,0 +1,12 @@
openapi: 3.0.3
info:
title: 出车实测凭证归属契约
version: 1.0.0
description: >-
复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。
businessType固定FLEET_TRIP_OPERATION_EVIDENCE,tenantId固定0,businessId固定Lastleg车次内部主键。
凭证为选填;上传时只接受非空PDF/JPG/PNG,最大10MiB,校验MIME与内容签名。
文件按平台、业务类型、车次及文件ID隔离;Lastleg持有当前/历史实测版本的附件引用及读写权限。
上传不自动保存实测记录,也不变更出车、库存、费用或签收事实;历史对象不硬删除。
Lastleg读取时先确认附件属于可见实测版本,再读取内容;不返回内部存储路径或匿名文件URL。
paths: {}
@@ -0,0 +1,79 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class TripOperationEvidenceFileTest {
@TempDir Path root;
private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE";
@Test void platformTripOwnershipIsRequiredAndDistinctFromOtherFleetFiles() {
var category = BusinessFileCategory.require(TYPE);
category.validateOwner(0);
for (long customer : new long[]{-1, 1, 900})
assertThatThrownBy(() -> category.validateOwner(customer)).isInstanceOf(BizException.class).hasMessageContaining("平台");
assertThat(category.storageBusinessId("71")).isEqualTo("71");
for (String id : new String[]{"0", "-1", "TRIP-71", "../71"})
assertThatThrownBy(() -> category.storageBusinessId(id)).isInstanceOf(BizException.class);
assertThat(category).isNotEqualTo(BusinessFileCategory.FLEET_DRIVER_LICENSE)
.isNotEqualTo(BusinessFileCategory.FLEET_VEHICLE_RECORD_PHOTO);
}
@Test void storesPdfAndImagesAndRejectsEmptySpoofedOrOversizeContent() throws Exception {
var storage = storage();
var pdf = new MockMultipartFile("file", "里程凭证.pdf", "application/pdf", "%PDF-1.7\nmeasured".getBytes());
var png = new MockMultipartFile("file", "油表.png", "image/png", image("png"));
var jpeg = new MockMultipartFile("file", "里程.jpg", "image/jpeg", image("jpeg"));
for (var file : new MockMultipartFile[]{pdf,png,jpeg}) {
var saved = storage.store(0,"71",file,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE);
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
}
for (var bad : new MockMultipartFile[]{
new MockMultipartFile("file","empty.pdf","application/pdf",new byte[0]),
new MockMultipartFile("file","fake.pdf","application/pdf","text".getBytes()),
new MockMultipartFile("file","fake.png","image/png","text".getBytes()),
new MockMultipartFile("file","notes.txt","text/plain","text".getBytes())})
assertThatThrownBy(() -> storage.store(0,"71",bad,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)).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,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE))
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
verify(oversized,never()).getInputStream();
}
@Test void uploadPreservesTripScopeAndWrongOwnerOrMissingBindingNeverReadsBytes() {
var mapper = mock(FileObjectMapper.class); var storage = storage();
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service,"files",mapper); ReflectionTestUtils.setField(service,"storage",storage);
when(mapper.insert(any(FileObjectEntity.class))).thenAnswer(call->{((FileObjectEntity)call.getArgument(0)).setId(501L);return 1;});
var upload = new MockMultipartFile("file","record.pdf","application/pdf","%PDF-1.7\nrecord".getBytes());
var metadata = service.upload(0,TYPE,"71",7,upload);
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); assertThat(metadata.businessType()).isEqualTo(TYPE);
assertThat(metadata.businessId()).isEqualTo("71"); assertThat(metadata.fileId()).isEqualTo(501L);
assertThatThrownBy(()->service.metadata(1,TYPE,"71",501)).isInstanceOf(BizException.class).hasMessageContaining("平台");
when(mapper.selectOne(any())).thenReturn(null);
assertThatThrownBy(()->service.download(0,TYPE,"72",501)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
private BusinessFileStorage storage() {
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root);
storage.initializeRoot(); return storage;
}
private byte[] image(String format) throws java.io.IOException {
var bytes = new java.io.ByteArrayOutputStream();
var image = new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_RGB);
assertThat(javax.imageio.ImageIO.write(image,format,bytes)).isTrue();
return bytes.toByteArray();
}
}