This commit is contained in:
2026-10-26 18:52:13 +08:00
parent c2735cd906
commit f6d9991955
17 changed files with 288 additions and 26 deletions
@@ -15,7 +15,7 @@ enum BusinessFileCategory {
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_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),
@@ -7,7 +7,7 @@ info:
复用InternalBusinessFileController,不增加公网File路径。
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
@@ -22,6 +22,12 @@ import static org.mockito.Mockito.when;
class BusinessFileStorageTest {
@TempDir Path root;
@Test void loadingLicenseStoresPdfButFuelStillRejectsPdf() {
var file=new MockMultipartFile("file","license.pdf","application/pdf","%PDF-1.4\n%%EOF".getBytes());
var stored=storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_LOADING_PHOTO);
assertThat(stored.contentType()).isEqualTo("application/pdf");
assertThatThrownBy(()->storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_FUEL_PHOTO)).isInstanceOf(BizException.class);
}
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
BusinessFileStorage storage = storage();
@@ -452,6 +452,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
}
masterPhotos.invalidateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class),savedResources);
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
// 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。
@@ -504,7 +505,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments());
if(loading) {
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name());
if(request.decision()==Decision.APPROVED) {
row.setStage(Stage.LOADING.name());
if(!"PICKUP".equals(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class));
}
} else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound())));
@@ -8,6 +8,11 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.util.List;
import java.util.ArrayList;
import java.util.Objects;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort;
import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
@@ -16,10 +21,63 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER
public class DispatchMasterPhotoService {
private final FleetResourcePhotoPort photos;
private final DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private DispatchLoadingPhotoPort loadingPhotos;
@jakarta.annotation.Resource private FleetTripExecutionService fleet;
/** 只读主档并通过本车文件网关创建证据。 */
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
this.photos=photos; this.evidence=evidence;
}
/** 资源保存时比较本趟资料来源;来源变化后旧类别不能继续充当新资源的凭证。 */
public void invalidateLoading(long dispatchId,String operator,Resources before,Resources after) {
var slots=java.util.EnumSet.noneOf(Slot.class);
if(!Objects.equals(vehicleSource(before.vehicle()),vehicleSource(after.vehicle())))slots.add(Slot.LOADING_LIST);
if(!Objects.equals(driverSource(before.driver()),driverSource(after.driver())))slots.add(Slot.LOADING_SCENE);
if(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots);
}
/** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */
private Object vehicleSource(VehicleSnapshot value) {
return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds()));
}
/** 临时司机同样比较身份与证照;人像、电话和预计结束时间不属于驾驶证来源。 */
private Object driverSource(DriverSnapshot value) {
if(value==null)return null;
var evidence=value.evidence();
return java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),evidence==null?null:evidence.licenseNo(),
evidence==null?null:sourceFiles(evidence.fileIds()),evidence==null?null:evidence.masterLicenseFileId());
}
/** 空附件与未提供附件等价,调整顺序不改变资料来源。 */
private java.util.Set<Long> sourceFiles(List<Long> ids) { return ids==null?java.util.Set.of():java.util.Set.copyOf(ids); }
/** 理货通过后把已审核的本趟快照复制进装车资料;不读取后来变更的主档、不复制司机人像。 */
public void populateLoading(long dispatchId,String operator,Resources value) {
var saved=loadingPhotos.read(dispatchId).photos();
var driver=value.driver();
if(driver!=null && driver.evidence()!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_SCENE)) {
var files=new ArrayList<MultipartFile>();
for(var id:driver.evidence().fileIds().stream().distinct().limit(5).toList()) {
if(driver.mode()==MASTER && driver.driverId()!=null && Objects.equals(id,driver.evidence().masterLicenseFileId())) {
var source=fleet.dispatchDriverLicense(driver.driverId(),id);
files.add(new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content()));
} else files.add(snapshot(dispatchId,DispatchEvidenceGateway.Kind.DRIVER,id));
}
loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_SCENE,files);
}
var vehicle=value.vehicle();
if(vehicle!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_LIST)) {
var files=new ArrayList<MultipartFile>();
for(var id:vehicle.evidenceFileIds().stream().distinct().toList()) {
var metadata=evidence.metadata(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id);
if(!List.of("image/jpeg","image/png").contains(metadata.contentType()))continue;
files.add(new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id)));
if(files.size()==5)break;
}
loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_LIST,files);
}
}
/** 复制前再次核验本趟及证据分类,不把外部文件编号直接绑定为装车资料。 */
private MultipartFile snapshot(long dispatchId,DispatchEvidenceGateway.Kind kind,long id) {
var metadata=evidence.metadata(dispatchId,kind,id);
return new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,kind,id));
}
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
public Resources populate(long dispatchId,String operator,Resources value) {
var v=value.vehicle(); var d=value.driver();
@@ -28,15 +28,15 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token;
}
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
public static void validate(Upload upload) {
public static void validate(PhotoSlot slot,Upload upload) {
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
|| !accepted(slot,upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("资料须为真实JPG/PNG;司机驾驶证也支持PDF,最大10MiB");
}
/** 使用原图片字节,未知结果不能换键或自动重传。 */
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
validate(upload);var body=new MultipartBodyBuilder();
validate(slot,upload);var body=new MultipartBodyBuilder();
body.part("file",new ByteArrayResource(upload.content()){
/** 保留本轮原文件名,不生成占位证据。 */
@Override public String getFilename(){return upload.fileName();}
@@ -64,15 +64,16 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
var m=response.data();
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !accepted(slot,m.contentType())
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
}
/** 不放开PDF、SVG或任意image类型。 */
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
/** 仅驾驶证类别接受PDF,其他装车及油耗类别仍只接受JPG/PNG。 */
private static boolean accepted(PhotoSlot slot,String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type) || slot==PhotoSlot.LOADING_SCENE && "application/pdf".equals(type);}
/** 使用真实文件头,不转换或伪造证据图片。 */
private static boolean signature(String type,byte[] bytes) {
if(bytes==null)return false;
if("application/pdf".equals(type))return bytes.length>=5 && bytes[0]=='%' && bytes[1]=='P' && bytes[2]=='D' && bytes[3]=='F' && bytes[4]=='-';
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
@@ -18,6 +18,10 @@ public interface DispatchLoadingPhotoPort {
record Download(Photo photo,byte[] content) {}
/** 只读取原整车附加资料版本与五框,不初始化记录。 */
State read(long dispatchId);
/** 已持排车父锁;仅为空类别复制本趟已核验资料,与理货审核同事务保存。 */
void importIfEmpty(long dispatchId,String operator,Slot slot,List<MultipartFile> files);
/** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */
void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set<Slot> slots);
/** 上传保存原请求意图;不提前计入已保存照片。 */
Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file);
/** 仅原操作者可恢复原上传结果。 */
@@ -31,6 +31,38 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
}
public record Binding(Slot slot,BindPhoto request) {}
/** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public void invalidateResourcePhotos(long id,String operator,Set<Slot> slots) {
if(!Set.of(Slot.LOADING_SCENE,Slot.LOADING_LIST).containsAll(slots))throw bad("仅可失效车辆或司机资料");
var row=repository.record(id);
if(row==null || slots.isEmpty())return;
row=repository.lock(row);
var current=projection.photos(row);
var kept=current.stream().filter(photo->slots.stream().noneMatch(slot->photo.slot()==slot(slot))).toList();
if(kept.size()==current.size())return;
long version=row.getRecordVersion();
row.setPhotosJson(codec.write(kept));row.setRecordVersion(Math.addExact(version,1));
row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion());
history.setAction("INVALIDATE_RESOURCE_PHOTOS");history.setOperatorId(operator);history.setReason("车辆或司机资料变化,原凭证失效");history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),kept,operator)));repository.history(history);
}
/** 自动带入只填空类;重复审核及已有现场照片不重复追加或覆盖。 */
@Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED)
public void importIfEmpty(long id,String operator,Slot slot,List<MultipartFile> sources) {
var state=read(id);
if(state.photos().stream().anyMatch(photo->photo.slot()==slot) || sources.isEmpty())return;
if(sources.size()>5)throw bad("每类装车资料最多5份");
for(var source:sources) {
try {
var payload=new OwnedBusinessFileGateway.Upload(source.getOriginalFilename(),source.getContentType(),source.getBytes());
var uploaded=files.upload(id,slot(slot),operator,payload);
state=bind(id,operator,slot,new BindPhoto(state.recordVersion(),UUID.randomUUID().toString(),Long.toString(uploaded.fileId())));
} catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); }
}
}
/** GET只读;首次保存前资料版本为0。 */
@Override public State read(long id) {
var row=repository.record(id);
@@ -43,7 +75,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
HttpOwnedBusinessFileGateway.validate(originalSlot,payload);
var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(id,operator(operator),key,identity);
if(!reservation.created())return uploaded(reservation.command());
@@ -43,7 +43,7 @@ public class OwnedBusinessPhotoService {
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
HttpOwnedBusinessFileGateway.validate(slot,payload);
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
if(!reservation.created()) {
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 排车装车资料(Admin/PDA共用)
version: 1.0.0
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史,重审通过后带入新资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
security:
- bearerAuth: []
paths:
@@ -84,7 +84,7 @@ paths:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB
idempotencyKey:
type: string
minLength: 8
@@ -124,7 +124,7 @@ paths:
file:
type: string
format: binary
description: 非空JPG/PNG,最大10MiB
description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB
idempotencyKey:
type: string
minLength: 8
@@ -204,6 +204,10 @@ paths:
schema:
type: string
format: binary
application/pdf:
schema:
type: string
format: binary
image/png:
schema:
type: string
@@ -225,6 +229,10 @@ paths:
schema:
type: string
format: binary
application/pdf:
schema:
type: string
format: binary
image/png:
schema:
type: string
@@ -75,7 +75,7 @@ paths:
type: object
required: [file, idempotencyKey]
properties:
file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" }
file: { type: string, format: binary, description: "非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,签名校验,最大10MiB;固定装车File分类" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
responses:
"200":
@@ -110,6 +110,7 @@ paths:
description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。"
content:
image/png: { schema: { type: string, format: binary } }
application/pdf: { schema: { type: string, format: binary } }
image/jpeg: { schema: { type: string, format: binary } }
/api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}:
parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ]
@@ -202,7 +203,7 @@ components:
category: { type: string, enum: [LOADING] }
fileId: { type: string }
fileName: { type: string }
contentType: { type: string, enum: [image/png, image/jpeg] }
contentType: { type: string, enum: [image/png, image/jpeg, application/pdf] }
sizeBytes: { type: string }
uploadedAt: { type: string, format: date-time }
savedAt: { type: string, format: date-time, nullable: true }
@@ -255,7 +255,7 @@ paths:
file:
type: "string"
format: "binary"
description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。"
description: "真实JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,非空,最大10MiB;与独立绑定命令使用不同请求键。"
"/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}":
parameters:
-
@@ -334,6 +334,8 @@ paths:
schema:
type: "string"
format: "binary"
"application/pdf":
schema: { type: string, format: binary }
"image/png":
schema:
type: "string"
@@ -642,6 +644,7 @@ components:
enum:
- "image/jpeg"
- "image/png"
- "application/pdf"
sizeBytes:
type: "string"
pattern: "^[1-9][0-9]*$"
@@ -642,7 +642,10 @@ public class DispatchAggregatePersistenceTest {
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class);
when(profilePhotos.current(any(),anyLong())).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(0,null));
org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,dispatchEvidence));
var masterPhotos=org.mockito.Mockito.spy(new DispatchMasterPhotoService(profilePhotos,dispatchEvidence));
org.springframework.test.util.ReflectionTestUtils.setField(masterPhotos,"loadingPhotos",mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class));
org.mockito.Mockito.doNothing().when(masterPhotos).populateLoading(anyLong(),anyString(),any());
org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",masterPhotos);
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
@@ -30,6 +30,68 @@ import static org.mockito.ArgumentMatchers.*;
/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */
class DispatchLoadingDeparturePersistenceTest {
/** 零扫码退回后换资源,旧凭证失效与资源保存原子提交;重审自动带入新资料供两端共用。 */
@Test void changedResourcesInvalidateOldDocumentsAndReapprovalImportsNewEvidence() throws Exception {
authorize();var f=new Fixture(false);long id=f.f.repository.find(f.number).getId();
for(int i=0;i<5;i++)f.bind(i,f.f.actor);
var old=f.photos.read(id);assertThat(DispatchLoadingDocumentService.complete(old.photos())).isTrue();
var master=(DispatchMasterPhotoService)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"masterPhotos");
org.springframework.test.util.ReflectionTestUtils.setField(master,"loadingPhotos",f.photos);
doCallRealMethod().when(master).populateLoading(anyLong(),anyString(),any());
var evidence=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"evidence");
when(evidence.download(anyLong(),any(),anyLong())).thenReturn(new byte[]{1,2,3});
var nextId=new java.util.concurrent.atomic.AtomicLong(3000);
when(f.files.upload(eq(id),any(),eq(f.f.actor.operatorId()),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(nextId.incrementAndGet(),"new.png","image/png",3,"2026-10-26T00:00:00Z"));
var returned=f.f.tx.execute(s->f.f.service.returnToReview(f.f.actor,f.number,new DispatchCommands.ReturnToReview(f.version,"return-change-resources","更换车辆司机")));
var input=f.f.configured("new-resources",List.of());var evidenceInput=input.driver().evidence();
var vehicle=new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"NEW-TRUCK","新承运商",input.vehicle().capacity(),List.of(111L));
var driver=new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"新司机","987654321",new DriverEvidence("NEW-LICENSE",evidenceInput.licenseExpiresOn(),List.of(222L),evidenceInput.expectedTaskEndAt(),List.of(333L)));
var request=new DispatchCommands.Update(returned.recordVersion(),"change-resources",vehicle,driver,input.plannedDepartureAt(),null,null,null);
var previousResources=f.f.repository.find(f.number).getResourcesJson();
assertThatThrownBy(()->f.f.tx.execute(s->{f.f.service.update(f.f.actor,f.number,request);throw new IllegalStateException("模拟后续保存失败");})).hasMessage("模拟后续保存失败");
assertThat(f.photos.read(id)).isEqualTo(old);
assertThat(f.f.repository.find(f.number).getResourcesJson()).isEqualTo(previousResources);
var updated=f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request));
var invalidated=f.photos.read(id);assertThat(invalidated.recordVersion()).isEqualTo("6");
assertThat(invalidated.photos()).containsExactlyElementsOf(old.photos().subList(2,5));
assertThat(DispatchLoadingDocumentService.complete(invalidated.photos())).isFalse();
assertThat(f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)).replayed()).isTrue();
assertThat(f.photos.read(id)).isEqualTo(invalidated);
assertThat(f.f.db.queryForObject("SELECT snapshot_json FROM ll_fleet_owned_business_history WHERE dispatch_id=? AND record_version=5",String.class,id)).contains("1000","1001");
f.f.prepareAppointmentNumbers(f.number);
var submitted=f.f.tx.execute(s->f.f.service.submitReview(f.f.actor,f.number,new DispatchCommands.SubmitReview(updated.recordVersion(),"resubmit-changed",null)));
var reviewed=f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch-changed",Decision.APPROVED,null,null)));
f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null)));
var admin=f.documents.get(f.f.actor,f.number);var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number);
assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(5);assertThat(admin.complete()).isTrue();
assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002","1002","1003","1004");
verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,222L);
verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,111L);
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000")))).hasMessageContaining("其他操作更新");
f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null)));
verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any());
}
@Test void importedFilesAreSharedAcrossActorsAndCountTowardDepartureWithoutDuplicateCopies() throws Exception {
authorize();var f=new Fixture();long id=f.f.repository.find(f.number).getId();
var image=new org.springframework.mock.web.MockMultipartFile("file","plate.png","image/png",new byte[]{1,2,3});
when(f.files.upload(eq(id),any(),eq("operator"),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(
call.getArgument(1)==com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot.LOADING_SCENE?2000:2001,"copied.png","image/png",3,"2026-10-26T00:00:00Z"));
f.f.tx.executeWithoutResult(s->{
f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image));
f.photos.importIfEmpty(id,"operator",Slot.LOADING_LIST,List.of(image));
});
var admin=f.documents.get(f.f.actor,f.number);
var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number);
assertThat(pda.photos()).isEqualTo(admin.photos()).hasSize(2);
assertThat(admin.complete()).isFalse();assertThat(admin.recordVersion()).isEqualTo("2");
f.f.tx.executeWithoutResult(s->f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image)));
verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any());
assertThat(f.photos.read(id).recordVersion()).isEqualTo("2");
f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete-imported")));
f.bind(2,f.f.actor);f.bind(3,f.f.actor);
assertThat(f.bind(4,new Actor("picker",ActorMode.PDA,"9")).stage()).isEqualTo("DELIVERING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
}
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
@@ -221,11 +283,15 @@ class DispatchLoadingDeparturePersistenceTest {
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements;
final OwnedBusinessFileGateway files=mock(OwnedBusinessFileGateway.class);
final DispatchLoadingPhotoAdapter photos;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception {
Fixture() throws Exception { this(true); }
/** 零扫码夹具用于退回重审;其余用例继续真实拣货。 */
Fixture(boolean picked) throws Exception {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5)))));
number=created.dispatchNo();version=f.approve(created).recordVersion();
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
if(picked) {f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");}
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash();
measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
@@ -259,9 +325,9 @@ class DispatchLoadingDeparturePersistenceTest {
f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"));
}
var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class));
var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class);
var codec=new OwnedBusinessSupport(f.json);
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z"));
var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos);
documents=new DispatchLoadingDocumentService(f.service,service,photos);
}
@@ -14,6 +14,56 @@ import static org.mockito.Mockito.*;
/** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */
class DispatchMasterPhotoServiceTest {
/** 只失效来源变化的类别;相同来源重审、无关资料修改和文件重排保留现场替换。 */
@Test void resourceChangesInvalidateOnlyAffectedCategories() {
var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class);
org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading);
var vehicle=new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L,102L));
var driver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(8L),null,List.of(90L),8L));
var original=new Resources(vehicle,driver,null);
service.invalidateLoading(9,"op",original,new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST","新承运商","IN_SERVICE",new Capacity(10,null,null),List.of(102L,101L)),
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","新电话",new DriverEvidence("LICENSE",null,List.of(8L),java.time.Instant.now(),List.of(91L),8L)),null));
verifyNoInteractions(loading);
var changedVehicle=new VehicleSnapshot(ResourceMode.MASTER,3L,"OTHER",null,"IN_SERVICE",null,List.of(103L));
service.invalidateLoading(9,"op",original,new Resources(changedVehicle,driver,null));
verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST));
clearInvocations(loading);
var renewedDriver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(9L),null,List.of(90L),9L));
service.invalidateLoading(9,"op",original,new Resources(vehicle,renewedDriver,null));
verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE));
clearInvocations(loading);
service.invalidateLoading(9,"op",original,new Resources(null,null,null));
verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE,
com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST));
}
@Test void importsFrozenLicenseAndVehicleIntoSharedLoadingCategoriesWithoutDriverPortrait() throws Exception {
var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class);
var fleet=mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class);
org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading);
org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",fleet);
when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("0",List.of()));
var license=new com.baiyue.lastleg.fleet.driver.DriverModels.File("8","license.pdf","application/pdf",8);
when(fleet.dispatchDriverLicense(2,8)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(license,"%PDF-1.4".getBytes()));
when(evidence.metadata(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new DispatchEvidenceGateway.File(101L,"plate.png","image/png",3));
when(evidence.download(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new byte[]{1,2,3});
var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L)),
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(102L),8L)),null);
service.populateLoading(9,"op",resources);
@SuppressWarnings("unchecked") var files=(ArgumentCaptor<List<MultipartFile>>)(ArgumentCaptor<?>)ArgumentCaptor.forClass(List.class);
verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE),files.capture());
assertThat(files.getValue()).hasSize(1);assertThat(files.getValue().get(0).getBytes()).isEqualTo("%PDF-1.4".getBytes());
verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST),files.capture());
assertThat(files.getValue().get(0).getOriginalFilename()).isEqualTo("plate.png");
verify(evidence,never()).download(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyLong());
verifyNoInteractions(photos);
var slots=com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.values();
var saved=java.util.Arrays.stream(slots).map(slot->new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Photo(slot,"LOADING","900"+slot.ordinal(),"saved.png","image/png","3",null,null)).toList();
when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("5",saved));
clearInvocations(loading,fleet,evidence);
service.populateLoading(9,"op",resources);
verify(loading,never()).importIfEmpty(anyLong(),anyString(),any(),anyList());
verifyNoInteractions(fleet,evidence);
}
final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class);
final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class);
final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence);
@@ -11,6 +11,23 @@ import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest {
@Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"copy-submit");
var approved=review(f,f.actor,submitted,false,"copy-dispatch",Decision.APPROVED,null);
var master=org.mockito.Mockito.mock(DispatchMasterPhotoService.class);
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"masterPhotos",master);
org.mockito.Mockito.doThrow(new IllegalStateException("文件服务不可用")).doNothing().when(master).populateLoading(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
assertThatThrownBy(()->review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null)).hasMessageContaining("文件服务不可用");
assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("BEFORE_LOADING");
assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("LOADING_PENDING");
var loaded=review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
org.mockito.Mockito.clearInvocations(master);
assertThat(review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null).recordVersion()).isEqualTo(loaded.recordVersion());
org.mockito.Mockito.verifyNoInteractions(master);
}
/** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */
@Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
@@ -11,9 +11,18 @@ import static org.junit.jupiter.api.Assertions.*;
class OwnedBusinessFileGatewayTest {
/** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */
@Test void invalidImageSignaturesAndMimeAreRejected() {
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
}
@Test void onlyDriverLicenseAcceptsPdfAndChecksMetadataScope() {
var pdf=new OwnedBusinessFileGateway.Upload("license.pdf","application/pdf","%PDF-1.4".getBytes());
assertDoesNotThrow(()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,pdf));
for(var slot:PhotoSlot.values())if(slot!=PhotoSlot.LOADING_SCENE)
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(slot,pdf));
var meta=new HttpOwnedBusinessFileGateway.Metadata(9L,0L,"OVERSEAS_DISPATCH_LOADING_PHOTO","1","license.pdf","application/pdf",8L,LocalDateTime.now());
assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_SCENE,9L).fileId());
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_LIST,9L));
}
/** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */
@Test void metadataCannotCrossCustomerDispatchOrCategory() {