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
@@ -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]*$"