feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
|
||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `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校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
|
||||
| 海外换标 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,6 +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_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),
|
||||
DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -59,6 +61,12 @@ enum BusinessFileCategory {
|
||||
}
|
||||
|
||||
void validateOwner(long tenantId) {
|
||||
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
||||
}
|
||||
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "驾驶证附件必须归属平台");
|
||||
}
|
||||
if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台");
|
||||
@@ -93,7 +101,7 @@ enum BusinessFileCategory {
|
||||
return Long.toString(value);
|
||||
}
|
||||
|
||||
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF }
|
||||
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, LICENSE, IMAGE }
|
||||
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
|
||||
private static final class Limits {
|
||||
private static final long STANDARD = 10L * 1024 * 1024;
|
||||
|
||||
@@ -64,6 +64,10 @@ public class BusinessFileStorage {
|
||||
verifyXlsx(file);
|
||||
extension = ".xlsx";
|
||||
} 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");
|
||||
extension = TYPES.get(type);
|
||||
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
|
||||
verifyContent(file, type);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 平台司机证照内部文件契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
|
||||
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。
|
||||
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
|
||||
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:
|
||||
summary: 上传平台司机驾驶证
|
||||
parameters:
|
||||
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/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:
|
||||
summary: 校验归属并读取元数据
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/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:
|
||||
summary: 校验归属并下载真实文件
|
||||
responses:
|
||||
'200':
|
||||
description: 文件内容,业务错误按JSON code识别
|
||||
content:
|
||||
application/pdf: {schema: {type: string, format: binary}}
|
||||
image/jpeg: {schema: {type: string, format: binary}}
|
||||
image/png: {schema: {type: string, format: binary}}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
components:
|
||||
securitySchemes:
|
||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
|
||||
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_DRIVER_LICENSE]}}
|
||||
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
Metadata:
|
||||
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string, nullable: true}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
fileId: {type: string}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
businessType: {type: string, enum: [FLEET_DRIVER_LICENSE]}
|
||||
businessId: {type: string}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||
@@ -0,0 +1,11 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆运维照片归属契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。
|
||||
businessType固定FLEET_VEHICLE_RECORD_PHOTO,tenantId固定0,businessId固定Lastleg运维记录内部主键。
|
||||
只接受非空JPG/PNG,最大10MiB,同时校验MIME及真实内容签名。拒绝其他客户、记录或类别引用。
|
||||
Lastleg负责车辆与记录归属、当前附件关联、权限、版本、上传幂等和不可变历史;File不读取Lastleg业务表。
|
||||
上传不会代表已核验、付款或车辆恢复可派;不返回对象存储路径或匿名图片URL。
|
||||
paths: {}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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.Mockito.*;
|
||||
|
||||
class DriverLicenseFileTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test void licensesArePlatformOwnedWithPositiveDriverId() {
|
||||
var category = BusinessFileCategory.require("FLEET_DRIVER_LICENSE");
|
||||
category.validateOwner(0);
|
||||
assertThatThrownBy(() -> category.validateOwner(1)).isInstanceOf(BizException.class).hasMessageContaining("平台");
|
||||
assertThatThrownBy(() -> category.validateOwner(-1)).isInstanceOf(BizException.class);
|
||||
assertThat(category.storageBusinessId("71")).isEqualTo("71");
|
||||
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception {
|
||||
var storage = storage();
|
||||
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
|
||||
var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE);
|
||||
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes());
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
|
||||
}
|
||||
|
||||
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
|
||||
var file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(10485761L);
|
||||
assertThatThrownBy(() -> storage().store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
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 java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class VehicleCarePhotoTest {
|
||||
@TempDir Path root;
|
||||
@Test void recordPhotosArePlatformOwnedAndRequireRealImageBytes() throws Exception {
|
||||
var category = BusinessFileCategory.require("FLEET_VEHICLE_RECORD_PHOTO");
|
||||
category.validateOwner(0);
|
||||
assertThatThrownBy(() -> category.validateOwner(7)).hasMessageContaining("平台");
|
||||
assertThatThrownBy(() -> category.validateOwner(-1)).hasMessageContaining("平台");
|
||||
assertThatThrownBy(() -> category.storageBusinessId("0")).hasMessageContaining("有效");
|
||||
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot();
|
||||
byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10};
|
||||
var result = storage.store(0, "71", new MockMultipartFile("file", "meter.png", "image/png", bytes), category);
|
||||
assertThat(storage.read(result.objectKey())).isEqualTo(bytes);
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "fake.png", "image/png", "not-an-image".getBytes()), category)).hasMessageContaining("不是有效图片");
|
||||
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user