Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b455c0756a | ||
|
|
a338d924c6 |
@@ -9,6 +9,10 @@ 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),
|
||||
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),
|
||||
@@ -67,7 +71,17 @@ enum BusinessFileCategory {
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
|
||||
}
|
||||
|
||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||
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, "排车车辆及司机证据必须归属平台");
|
||||
}
|
||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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: {}
|
||||
@@ -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<FileObjectEntity> 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<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
|
||||
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
|
||||
private BusinessFileStorage storage() {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
|
||||
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
|
||||
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
/** 提供具有真实 PDF 文件头的最小上传内容。 */
|
||||
private static MockMultipartFile pdf() {
|
||||
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
|
||||
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
|
||||
private static byte[] image(String format) throws java.io.IOException {
|
||||
var output = new ByteArrayOutputStream();
|
||||
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -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<FileObjectEntity> 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<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
|
||||
.matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("scope column %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
private static byte[] image(String format) throws Exception {
|
||||
var bytes = new ByteArrayOutputStream();
|
||||
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
|
||||
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user