feat(fleet): attach scoped evidence to measured trip records
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
|
||||
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
|
||||
| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 |
|
||||
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
||||
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
|
||||
| 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user