Merge pull request 'feat(lastleg): 排车复用车辆司机档案照片并支持装车多图' (#37) from hotfix-gray-bugfix into main

This commit was merged in pull request #37.
This commit is contained in:
2026-10-26 17:15:07 +08:00
45 changed files with 961 additions and 35 deletions
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
@@ -0,0 +1,16 @@
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
+16 -1
View File
@@ -4987,4 +4987,19 @@ CREATE TABLE ll_document_sequence (
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
CREATE TABLE ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 235 AS initialized_tables;
@@ -7,6 +7,8 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory {
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -90,7 +92,7 @@ enum BusinessFileCategory {
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
}
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
}
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
@@ -0,0 +1,88 @@
openapi: 3.0.3
info:
title: 车辆与司机档案照片内部文件契约
version: 1.0.0
description: >-
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
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:
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_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
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_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
businessId: {type: string}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [image/jpeg, image/png]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
@@ -0,0 +1,16 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class FleetResourcePhotoCategoryTest {
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
category.validateOwner(0);
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
}
}
}
+3 -1
View File
@@ -87,7 +87,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -162,3 +162,5 @@
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。
@@ -264,3 +264,7 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。
### 档案照片只读复用边界(2026-10-26)
`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。
@@ -64,6 +64,8 @@ test('fleet stays a business slice and its policies have no infrastructure depen
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
assert.ok(dispatchEdges.length > 0);
assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService'
&& ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter'
&& /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target)
|| edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper'
@@ -82,6 +84,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen
'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged',
'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService',
'TransportTaskService->reporting.TripOperationsEvidenceGateway',
'TransportTaskService->photo.FleetResourcePhotoModels',
// 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。
'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity',
].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)),
@@ -33,6 +33,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos;
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@@ -310,7 +311,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
Resources resources=masterPhotos.populate(id,actor.operatorId(),resources(shell,photos,null)); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -443,7 +444,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
var savedResources=resources(actual,photos,request.maintenance());
var savedResources=masterPhotos.populate(row.getId(),actor.operatorId(),resources(actual,photos,request.maintenance()));
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
var savedDriver=savedResources.driver();
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
@@ -794,21 +795,22 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */
/** 车辆和司机须有有效图片,驾驶证接受与档案一致的PDF或图片。 */
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
var driver=resources.driver();var proof=driver.evidence();
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
}
/** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */
/** 文件存在、归属及有效性由原提供者复验,PDF仅可作为驾驶证。 */
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
boolean image=false;
for(var id:ids) {
var master=masterEvidenceDriverId(row,kind,id);
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true;
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())
|| kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER && "application/pdf".equals(file.contentType())))image=true;
}
if(!image)throw bad("请上传至少一张有效"+label);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind;
import com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER;
/** 档案照片只在缺少本车照片时带入,复制为本车证据以冻结历史与文件权限。 */
@Service
public class DispatchMasterPhotoService {
private final FleetResourcePhotoPort photos;
private final DispatchEvidenceGateway evidence;
/** 只读主档并通过本车文件网关创建证据。 */
public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) {
this.photos=photos; this.evidence=evidence;
}
/** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */
public Resources populate(long dispatchId,String operator,Resources value) {
var v=value.vehicle(); var d=value.driver();
if(v!=null && v.mode()==MASTER && v.vehicleId()!=null) {
var ids=populate(dispatchId,operator,Kind.VEHICLE,v.vehicleId(),v.evidenceFileIds());
v=new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),ids);
}
if(d!=null && d.mode()==MASTER && d.driverId()!=null && d.evidence()!=null) {
var e=d.evidence();
var ids=populate(dispatchId,operator,Kind.DRIVER,d.driverId(),e.photoFileIds());
d=new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),new DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt(),ids,e.masterLicenseFileId()));
}
return new Resources(v,d,value.maintenance());
}
/** 只复制已经由档案服务确认的图片,上传失败使业务事务整体失败。 */
public List<Long> populate(long dispatchId,String operator,Kind kind,long resourceId,List<Long> existing) {
if(existing!=null && !existing.isEmpty())return List.copyOf(existing);
var current=photos.current(kind,resourceId);
if(current.photo()==null)return List.of();
var source=photos.download(kind,resourceId,Long.parseLong(current.photo().fileId()));
var file=new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content());
var copied=evidence.upload(dispatchId,kind==Kind.VEHICLE?DispatchEvidenceGateway.Kind.VEHICLE:DispatchEvidenceGateway.Kind.DRIVER_PHOTO,operator,file);
return List.of(copied.fileId());
}
/** 只供服务间复制已核验图片,不接受任意磁盘路径。 */
private record SnapshotFile(String filename,String contentType,byte[] bytes) implements MultipartFile {
/** 上传部件固定使用文件字段。 */
@Override public String getName() { return "file"; }
/** 保留已核验主档文件名。 */
@Override public String getOriginalFilename() { return filename; }
/** 沿用已核验图片类型。 */
@Override public String getContentType() { return contentType; }
/** 拒绝空图片。 */
@Override public boolean isEmpty() { return bytes.length==0; }
/** 使用实际读取的字节长度。 */
@Override public long getSize() { return bytes.length; }
/** 返回本次复制的图片内容。 */
@Override public byte[] getBytes() { return bytes; }
/** 提供上传流,不读取磁盘文件。 */
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
/** 实现文件上传协议要求的输出方法。 */
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(),bytes); }
}
}
@@ -36,6 +36,7 @@ public class TransportTaskService {
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService masterPhotos;
/** 复用已有资格、占用、原单及凭证能力。 */
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
@@ -119,6 +120,20 @@ public class TransportTaskService {
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
// 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。
var supplied=request.reviewPhotos();
var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE,
request.vehicleId(),supplied==null?List.of():supplied.vehicleFileIds());
var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,
request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds());
var license=request.masterLicenseFileId();
if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) {
var driver=drivers.detail(request.driverId(),null);
if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId());
}
request=new TransportTaskModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason(),
request.dispatchRecordVersion(),new com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments(
vehiclePhotos,driverPhotos,supplied==null?List.of():supplied.licenseFileIds()),license);
photos=reviewPhotos.validate(row,trip,request);
}
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
@@ -27,7 +27,10 @@ public final class OutsourcedBusinessModels {
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加装车照片。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
}
@@ -53,7 +53,7 @@ public class OutsourcedBusinessService {
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
if(request==null)throw bad("照片绑定请求缺失");
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId(),request.replaceFileId()),SCOPE));
}
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
@@ -34,7 +34,11 @@ public final class OwnedBusinessModels {
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加装车照片。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
}
public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
@@ -8,7 +8,11 @@ import org.springframework.web.multipart.MultipartFile;
public interface DispatchLoadingPhotoPort {
enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT }
record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId,
@Pattern(regexp="[1-9][0-9]*") String replaceFileId) {
/** 未指定替换目标时追加照片,每类最多五张。 */
public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); }
}
record State(String recordVersion,List<Photo> photos) {}
record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List<Photo> photos) {}
record Download(Photo photo,byte[] content) {}
@@ -77,9 +77,9 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);
if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对");
var photos=new EnumMap<PhotoSlot,OwnedBusinessModels.Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now()));
var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
row.setPhotosJson(codec.write(photos));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("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt());
@@ -104,6 +104,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort {
private void validate(Slot slot,BindPhoto request) {
slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion());
if(codec.version(request.fileId())<=0)throw bad("文件编号无效");
if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效");
}
/** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */
private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Photo;
import java.util.ArrayList;
import java.util.List;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 照片按文件追加或精确替换,禁止按类别压缩而丢失其他已保存图片。 */
final class OwnedBusinessPhotoBindings {
/** 工具类仅提供照片引用合并。 */
private OwnedBusinessPhotoBindings() {}
/** 装车每类最多五张;油耗仍为单张,替换目标必须在当前同类引用中。 */
static List<Photo> bind(List<Photo> existing,Photo photo,String replaceFileId) {
var result=new ArrayList<>(existing);
// 油耗沿原单张替换协议,不能因装车改多图而改变其他入口的保存行为。
if(replaceFileId==null && "FUEL".equals(photo.category()))
replaceFileId=existing.stream().filter(p->p.slot()==photo.slot()).map(Photo::fileId).findFirst().orElse(null);
if(replaceFileId!=null) {
int index=-1;
for(int i=0;i<result.size();i++)if(result.get(i).slot()==photo.slot() && result.get(i).fileId().equals(replaceFileId))index=i;
if(index<0)throw conflict("待替换照片已变化,请刷新后核对");
result.remove(index);
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
result.add(index,photo);
} else {
if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存");
int limit="LOADING".equals(photo.category())?5:1;
if(result.stream().filter(p->p.slot()==photo.slot()).count()>=limit)throw conflict("该类照片最多"+limit+"张,请替换已有照片");
result.add(photo);
}
return List.copyOf(result);
}
}
@@ -69,20 +69,20 @@ public class OwnedBusinessRecordService {
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version);
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
var saved=new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString());
var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId());
row.setPhotosJson(codec.write(photos));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
}
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
private String photoReason(PhotoSlot slot) {
return switch(slot) {
case LOADING_SCENE -> "保存装车现场照片";
case LOADING_LIST -> "保存装车清单照片";
case PRE_DEPARTURE_CHECK -> "保存出车前检查照片";
case DRIVER_LICENSE -> "保存驾驶证附件照片";
case RECEIPT -> "保存签收凭证照片";
case LOADING_SCENE -> "保存司机驾驶证照片";
case LOADING_LIST -> "保存车牌照片";
case PRE_DEPARTURE_CHECK -> "保存装车完成照片";
case DRIVER_LICENSE -> "保存封车照片";
case RECEIPT -> "保存BOL司机签字照片";
case FUEL_START -> "保存起始油耗表盘照片";
case FUEL_END -> "保存结束油耗表盘照片";
};
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.fleet.photo;
import org.springframework.web.multipart.MultipartFile;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** File按平台、档案类型、资源身份及文件身份隔离照片。 */
public interface FleetResourcePhotoGateway {
/** 上传平台所属的档案照片。 */
Photo upload(Kind kind, long id, String operator, MultipartFile file);
/** 核验档案类型、资源身份和文件身份。 */
Photo metadata(Kind kind, long id, long file);
/** 读取已核验归属的图片内容。 */
byte[] download(Kind kind, long id, long file);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.photo;
import org.apache.ibatis.annotations.*;
/** 仅操作档案照片版本表;资源有效性通过所属领域服务核验。 */
@Mapper
public interface FleetResourcePhotoMapper {
/** 读取同一档案的最大照片版本。 */
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} ORDER BY record_version DESC LIMIT 1")
FleetResourcePhotoModels.Stored current(@Param("kind") String kind, @Param("id") long id);
/** 按档案与文件复合身份查询历史引用。 */
@Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} AND file_id=#{file} LIMIT 1")
FleetResourcePhotoModels.Stored file(@Param("kind") String kind, @Param("id") long id, @Param("file") long file);
/** 追加不可变照片版本,数据库拒绝重复资源版本。 */
@Insert("INSERT INTO ll_fleet_resource_photo(resource_kind,resource_id,record_version,file_id,file_name,content_type,size_bytes,created_by,created_at) VALUES(#{resourceKind},#{resourceId},#{recordVersion},#{fileId},#{fileName},#{contentType},#{sizeBytes},#{createdBy},#{createdAt})")
int insert(FleetResourcePhotoModels.Stored row);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.photo;
import java.time.LocalDateTime;
/** 档案照片独立版本化,历史图片不覆盖且不改变驾驶证审核结论。 */
public final class FleetResourcePhotoModels {
/** 仅承载照片接口与持久化数据。 */
private FleetResourcePhotoModels() { }
public enum Kind { VEHICLE, DRIVER }
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
public record Detail(long recordVersion, Photo photo) { }
public record Stored(String resourceKind, long resourceId, long recordVersion, long fileId,
String fileName, String contentType, long sizeBytes, String createdBy, LocalDateTime createdAt) { }
public record Download(Photo metadata, byte[] content) { }
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.fleet.photo;
import org.springframework.stereotype.Repository;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 同一资源版本唯一,阻止并发替换静默覆盖。 */
@Repository
public class FleetResourcePhotoRepository {
private final FleetResourcePhotoMapper mapper;
/** 注入唯一照片Mapper。 */
public FleetResourcePhotoRepository(FleetResourcePhotoMapper mapper) { this.mapper = mapper; }
/** 读取指定档案的最新照片版本。 */
public Stored current(Kind kind, long id) { return mapper.current(kind.name(), id); }
/** 按档案及文件身份读取不可变历史引用。 */
public Stored file(Kind kind, long id, long file) { return mapper.file(kind.name(), id, file); }
/** 追加新版本,唯一键负责并发冲突保护。 */
public void append(Stored row) { if (mapper.insert(row) != 1) throw new IllegalStateException("档案照片未保存"); }
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 管理可重复用于排车的车辆及司机照片,排车另存当趟不可变副本。 */
@Service
public class FleetResourcePhotoService implements com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort {
private final FleetResourcePhotoRepository repository;
private final FleetResourcePhotoGateway files;
private final VehicleRepository vehicles;
private final DriverService drivers;
/** 通过资源领域入口确认身份,所有照片版本经Repository保存。 */
public FleetResourcePhotoService(FleetResourcePhotoRepository repository, FleetResourcePhotoGateway files,
VehicleRepository vehicles, DriverService drivers) {
this.repository=repository; this.files=files; this.vehicles=vehicles; this.drivers=drivers;
}
/** 先确认资源存在,空照片是明确的可补齐状态。 */
public Detail current(Kind kind, long id) {
requireResource(kind,id);
var row=repository.current(kind,id);
return row==null ? new Detail(0,null) : new Detail(row.recordVersion(),photo(row));
}
/** 版本随照片独立递增;失败保留旧照片及历史。 */
@Transactional
public Detail upload(Kind kind,long id,long version,String operator,MultipartFile file) {
var current=current(kind,id);
if(version!=current.recordVersion())throw conflict();
var saved=files.upload(kind,id,operator,file);
try {
repository.append(new Stored(kind.name(),id,version+1,Long.parseLong(saved.fileId()),saved.fileName(),
saved.contentType(),saved.sizeBytes(),operator,LocalDateTime.now(ZoneOffset.UTC)));
} catch(DuplicateKeyException error) { throw conflict(); }
return new Detail(version+1,saved);
}
/** 只允许读取本档案已关联的当前或历史图片。 */
public Download download(Kind kind,long id,long file) {
requireResource(kind,id);
if(repository.file(kind,id,file)==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"档案照片不存在");
var metadata=files.metadata(kind,id,file);
var bytes=files.download(kind,id,file);
if(bytes.length!=metadata.sizeBytes())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"档案照片内容不完整");
return new Download(metadata,bytes);
}
/** 车辆及司机身份必须真实存在,不能按自由文本上传。 */
private void requireResource(Kind kind,long id) {
if(id<=0)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"档案身份无效");
if(kind==Kind.VEHICLE)vehicles.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"车辆档案不存在")); else drivers.detail(id,null);
}
/** 转换为不暴露持久化信息的文件元数据。 */
private static Photo photo(Stored row) { return new Photo(Long.toString(row.fileId()),row.fileName(),row.contentType(),row.sizeBytes()); }
/** 冲突要求重读当前照片,不自动覆盖他人的修改。 */
private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT,"档案照片已变化,请重新读取后上传"); }
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.fleet.photo;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpFleetResourcePhotoGateway implements FleetResourcePhotoGateway {
private static final String PATH = "/internal/file/v1/business-files/0/{type}/{record}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
/** 使用统一内部服务客户端与File通信。 */
public HttpFleetResourcePhotoGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
/** 只接受非空图片,通过内部File上传并校验归属。 */
@Override public Photo upload(Kind kind, long recordId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("档案照片须为非空 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取档案照片"); }
var result = client.post().uri(PATH, type(kind), recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, kind, recordId, null);
}
/** 逐文件核验平台及当前档案复合归属。 */
@Override public Photo metadata(Kind kind, long recordId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), kind, recordId, fileId);
}
/** 按原档案身份读取图片,不返回匿名URL。 */
@Override public byte[] download(Kind kind, long recordId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
/** 拒绝错误正文、空文件和超过上限的内容。 */
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
/** 验证File返回的身份、类型及字节数。 */
static Photo verified(Envelope result, Kind kind, long recordId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !type(kind).equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前资源档案或不是有效照片");
return new Photo(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
/** 档案类型不可由调用方拼接任意文件业务类别。 */
private static String type(Kind kind) { return kind == Kind.VEHICLE ? "FLEET_VEHICLE_PHOTO" : "FLEET_DRIVER_PHOTO"; }
/** 车辆和司机本人照片仅允许JPG及PNG。 */
private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); }
/** 返回可修正的附件业务错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
/** 外部失败保留诊断,不伪装为成功。 */
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "档案照片不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.constraints.Min;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 复用车辆编辑、司机上传及各自列表权限,不扩大跨档案访问域。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}")
public class PlatformFleetResourcePhotoController {
private final FleetResourcePhotoService service;
/** 注入档案照片应用服务。 */
public PlatformFleetResourcePhotoController(FleetResourcePhotoService service) { this.service=service; }
/** 列表权限读取档案照片及版本。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
public ApiResponse<Detail> current(@PathVariable Kind kind,@PathVariable @Min(1) long id) {
return ApiResponse.success(service.current(kind,id));
}
/** 按资源类型校验上传权限并提交当前版本。 */
@PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("(#kind.name() == 'VEHICLE' and hasAuthority('platform:overseas-master-data:vehicle:edit')) or (#kind.name() == 'DRIVER' and hasAuthority('platform:overseas-master-data:driver:upload'))")
public ApiResponse<Detail> upload(@PathVariable Kind kind,@PathVariable @Min(1) long id,
@RequestParam @Min(0) long recordVersion,@RequestPart("file") MultipartFile file,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.upload(kind,id,recordVersion,user.userId().toString(),file));
}
/** 原档案读取权限包含其已关联的历史图片。 */
@GetMapping("/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')")
public ResponseEntity<byte[]> download(@PathVariable Kind kind,@PathVariable @Min(1) long id,@PathVariable @Min(1) long fileId) {
var file=service.download(kind,id,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.metadata().fileName(),StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
}
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.fleet.photo.port;
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
/** 排车仅通过此只读应用端口获取档案照片,不访问照片持久化或改写档案。 */
public interface FleetResourcePhotoPort {
/** 返回当前档案照片及其独立版本,缺失时photo为空。 */
Detail current(Kind kind,long id);
/** 读取已经关联本档案的图片并复验平台文件归属。 */
Download download(Kind kind,long id,long file);
}
@@ -4658,3 +4658,17 @@ CREATE TABLE IF NOT EXISTS ll_document_sequence (
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
PRIMARY KEY (resource_kind, resource_id, record_version),
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 排车装车资料(Admin/PDA共用)
version: 1.0.0
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。
security:
- bearerAuth: []
paths:
@@ -375,7 +375,7 @@ components:
- DELIVERED
complete:
type: boolean
description: 五个固定框各有一张已保存照片
description: 五类各至少一张已保存照片,不能用总张数代替类别齐全
BindPhoto:
type: object
required:
@@ -392,3 +392,8 @@ components:
maxLength: 128
fileId:
type: string
replaceFileId:
type: string
nullable: true
pattern: "^[1-9][0-9]*$"
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
@@ -240,3 +240,8 @@ components:
maxLength: 128
fileId:
type: string
replaceFileId:
type: string
nullable: true
pattern: "^[1-9][0-9]*$"
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
@@ -175,6 +175,12 @@ components:
recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" }
idempotencyKey: { type: string, minLength: 8, maxLength: 128 }
fileId: { type: string, pattern: "^[1-9][0-9]*$" }
replaceFileId:
type: string
nullable: true
pattern: "^[1-9][0-9]*$"
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
Vehicle:
type: object
x-java-record: OutsourcedBusinessModels.Vehicle
@@ -500,6 +500,12 @@ components:
fileId:
type: "string"
pattern: "^[1-9][0-9]*$"
replaceFileId:
type: string
nullable: true
pattern: "^[1-9][0-9]*$"
description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。
Gate:
type: "object"
x-java-record: "OwnedBusinessModels.Gate"
@@ -0,0 +1,82 @@
openapi: 3.0.3
info:
title: 车辆与司机档案照片
version: 1.0.0
description: >-
档案照片使用独立版本并保留历史。车辆读取沿vehicle:list、上传沿vehicle:edit;
司机读取沿driver:list、上传沿driver:upload,均位于platform:overseas-master-data权限域。
只接受非空JPG/PNG,最大10MiB。排车保存时仅对缺失的车辆/司机照片复制当趟附件;
后续更换档案照片不覆盖已有排车照片,不创建临时外配主档,不改变驾驶证审核。
驾驶证仍走既有license接口;排车审核与主档统一接受PDF/JPG/PNG。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}:
parameters:
- {$ref: '#/components/parameters/Kind'}
- {$ref: '#/components/parameters/Id'}
get:
summary: 读取当前档案照片,未上传时版本为0且photo为空
responses:
'200': {$ref: '#/components/responses/Detail'}
'403': {description: 缺少该类档案list权限}
post:
summary: 上传或替换档案照片,照片版本冲突时不覆盖
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [recordVersion, file]
properties:
recordVersion: {type: string, pattern: '^(0|[1-9][0-9]*)$'}
file: {type: string, format: binary}
responses:
'200': {$ref: '#/components/responses/Detail'}
'403': {description: 缺少车辆edit或司机upload权限}
/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/Kind'}
- {$ref: '#/components/parameters/Id'}
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
get:
summary: 读取本档案已关联的当前或历史图片,no-store且无匿名URL
responses:
'200':
description: 图片内容;业务错误为统一JSON响应,不可当成图片
content:
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
'403': {description: 缺少该类档案list权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters:
Kind: {name: kind, in: path, required: true, schema: {type: string, enum: [VEHICLE, DRIVER]}}
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
Detail:
description: 业务成功code为200;版本冲突或归属无效通过业务错误返回
content:
application/json:
schema:
type: object
required: [code, data]
properties:
code: {type: string}
message: {type: string}
data:
type: object
required: [recordVersion, photo]
properties:
recordVersion: {type: string, pattern: '^(0|[1-9][0-9]*)$'}
photo:
type: object
nullable: true
required: [fileId, fileName, contentType, sizeBytes]
properties:
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
fileName: {type: string}
contentType: {type: string, enum: [image/jpeg, image/png]}
sizeBytes: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -639,6 +639,9 @@ public class DispatchAggregatePersistenceTest {
var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class);
when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10));
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));
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)));
@@ -17,8 +17,9 @@ import static org.mockito.Mockito.*;
/** 排车反馈直接回归:图片送审门禁与正式预约的多货件投影。 */
class DispatchFeedbackRegressionTest {
/** 主档PDF与本趟补传图片分属各自文件域,补传不能被主档重建清空。 */
@Test void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner() throws Exception {
/** 主档PDF可以单独通过驾驶证校验,也可与本趟补传图片共存;司机照片自动带入。 */
@ParameterizedTest @ValueSource(booleans={false,true})
void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner(boolean supplement) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("master-photo",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)");
@@ -30,15 +31,23 @@ class DispatchFeedbackRegressionTest {
when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(java.time.LocalDate.of(2099,1,1));
when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver);
when(drivers.download(71L,81L)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(new com.baiyue.lastleg.fleet.driver.DriverModels.File("81","主档证照.pdf","application/pdf",3),new byte[]{1,2,3}));
var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class);
var photo=new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Photo("88","司机.png","image/png",3);
when(profilePhotos.current(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(1,photo));
when(profilePhotos.download(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71,88)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Download(photo,new byte[]{1,2,3}));
var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
when(evidence.upload(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyString(),any())).thenReturn(new DispatchEvidenceGateway.File(303L,"司机.png","image/png",3));
ReflectionTestUtils.setField(f.service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,evidence));
var input=f.configured("update",List.of());
var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(),
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))),
new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,supplement?List.of(91L):List.of(),input.driver().evidence().expectedTaskEndAt(),List.of())),
input.plannedDepartureAt(),null,null,null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
f.prepareAppointmentNumbers(created.dispatchNo());
var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence();
assertThat(proof.fileIds()).containsExactly(81L,91L);assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png");
assertThat(proof.fileIds()).containsExactlyElementsOf(supplement?List.of(81L,91L):List.of(81L));assertThat(proof.masterLicenseFileId()).isEqualTo(81L);
assertThat(proof.photoFileIds()).containsExactly(303L);
if(supplement)assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png");
assertThat(f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-master",null))).recordVersion()).isGreaterThan(saved.recordVersion());
verify(drivers,never()).download(71L,91L);
}
@@ -183,6 +183,33 @@ class DispatchLoadingDeparturePersistenceTest {
verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 同类可保存五张但不能充当五类;追加、替换、版本与幂等共同保护原照片。 */
@Test void multiplePhotosPerCategoryPreserveReferencesAndDepartureGate() throws Exception {
authorize();var f=new Fixture();
for(int i=0;i<5;i++) {
var request=new BindPhoto(Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i));
assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).complete()).isFalse();
}
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5);
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","too-many-photo","3000")))).hasMessageContaining("最多5张");
assertThat(f.documents.get(f.f.actor,f.number).recordVersion()).isEqualTo("5");
var replace=new BindPhoto("5","replace-photo","3000","2002");
var result=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace));
assertThat(result.photos()).extracting(p->p.fileId()).containsExactly("2000","2001","3000","2003","2004");
var replay=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace));
assertThat(replay).isEqualTo(result);
assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,new BindPhoto("6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化");
for(int i=1;i<5;i++) {
var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=new BindPhoto(Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i));
f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,slot,request));
}
assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(9);
assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue();
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("multi-ready"))).stage()).isEqualTo(Stage.DELIVERING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());
}
/** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode;
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */
class DispatchMasterPhotoServiceTest {
final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class);
final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class);
final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence);
@Test void copiesBothProfilesAndKeepsFrozenPhotosWithoutReadingChangedMaster() throws Exception {
var photo=new Photo("81","profile.png","image/png",3);
when(photos.current(any(),anyLong())).thenReturn(new Detail(1,photo));
when(photos.download(any(),anyLong(),eq(81L))).thenReturn(new Download(photo,new byte[]{1,2,3}));
when(evidence.upload(eq(9L),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq("op"),any())).thenReturn(new DispatchEvidenceGateway.File(101L,"vehicle.png","image/png",3));
when(evidence.upload(eq(9L),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),eq("op"),any())).thenReturn(new DispatchEvidenceGateway.File(102L,"driver.png","image/png",3));
var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of()),
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(),8L)),null);
var saved=service.populate(9,"op",resources);
assertThat(saved.vehicle().evidenceFileIds()).containsExactly(101L);
assertThat(saved.driver().evidence().photoFileIds()).containsExactly(102L);
assertThat(saved.driver().evidence().masterLicenseFileId()).isEqualTo(8L);
var file=ArgumentCaptor.forClass(MultipartFile.class);
verify(evidence).upload(eq(9L),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq("op"),file.capture());
assertThat(file.getValue().getBytes()).containsExactly(1,2,3);
clearInvocations(photos,evidence);
assertThat(service.populate(9,"op",saved)).isEqualTo(saved);
verifyNoInteractions(photos,evidence);
}
@Test void missingProfileRemainsMissingAndCopyFailureIsNotHidden() {
when(photos.current(Kind.VEHICLE,1)).thenReturn(new Detail(0,null));
assertThat(service.populate(9,"op",Kind.VEHICLE,1,List.of())).isEmpty();
verifyNoInteractions(evidence);
when(photos.current(Kind.VEHICLE,1)).thenThrow(new IllegalStateException("File unavailable"));
assertThatThrownBy(() -> service.populate(9,"op",Kind.VEHICLE,1,List.of())).hasMessage("File unavailable");
}
@Test void externalResourcesNeverReadOrCreateMasterProfiles() {
var resources=new Resources(new VehicleSnapshot(ResourceMode.EXTERNAL,null,"TEST",null,null,null,List.of()),
new DriverSnapshot(ResourceMode.EXTERNAL,null,"司机","电话",new DriverEvidence(null,null,List.of(),null,List.of())),null);
assertThat(service.populate(9,"op",resources)).isEqualTo(resources);
verifyNoInteractions(photos,evidence);
}
}
@@ -148,13 +148,13 @@ class OwnedBusinessPersistenceTest {
var original=new BindPhoto("0","bind-original","9");
var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first);
assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt());
assertEquals("保存装车现场照片",repository.revision(1,1).getReason());
assertEquals("保存司机驾驶证照片",repository.revision(1,1).getReason());
assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion());
var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null);
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10"),replacement);
service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10","9"),replacement);
assertEquals("10",projection.photos(repository.record(1)).get(0).fileId());
assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId());
assertEquals("保存装车现场照片",repository.revision(1,2).getReason());
assertEquals("保存司机驾驶证照片",repository.revision(1,2).getReason());
var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class);
when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2});
var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file);
@@ -247,7 +247,7 @@ class OwnedBusinessScopePersistenceTest {
}
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId(),firstFile));
final long oldId=Long.parseLong(firstFile);
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class OwnedBusinessPhotoBindingsTest {
/** 保存油耗时不得压缩PDA同类多张装车照片。 */
@Test void fuelReplacementPreservesEveryLoadingPhoto() {
var existing=List.of(photo(PhotoSlot.LOADING_SCENE,"1"),photo(PhotoSlot.LOADING_SCENE,"2"),photo(PhotoSlot.LOADING_LIST,"3"),photo(PhotoSlot.FUEL_START,"4"));
var saved=OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.FUEL_START,"5"),"4");
assertThat(saved).extracting(Photo::fileId).containsExactly("1","2","3","5");
assertThat(OwnedBusinessPhotoBindings.bind(saved,photo(PhotoSlot.FUEL_START,"6"),null)).extracting(Photo::fileId).containsExactly("1","2","3","6");
}
/** 同一文件不能重复绑定或借替换覆盖另一个类别。 */
@Test void rejectsDuplicateAndStaleReplacement() {
var existing=List.of(photo(PhotoSlot.LOADING_SCENE,"1"),photo(PhotoSlot.LOADING_SCENE,"2"));
assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_SCENE,"1"),null)).hasMessageContaining("重复");
assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_SCENE,"2"),"1")).hasMessageContaining("重复");
assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_LIST,"3"),"1")).hasMessageContaining("待替换");
}
/** 使用不含真实用户资料的固定文件元数据。 */
private Photo photo(PhotoSlot slot,String id) { return new Photo(slot,slot.category(),id,"test.png","image/png","5","2026-10-26T00:00:00Z","2026-10-26T00:00:00Z"); }
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.fleet.photo;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.UUID;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import static org.assertj.core.api.Assertions.*;
/** 使用真实建表定义与Mapper验证类型隔离、最新版本及不可变历史。 */
class FleetResourcePhotoPersistenceTest {
@Test void preservesHistoryAndRejectsTwoWritesToSameVersion() throws Exception {
var source=new JdbcDataSource();source.setURL("jdbc:h2:mem:photos_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var ddl=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ("));
try(var connection=source.getConnection();var statement=connection.createStatement()) { statement.execute(ddl); }
var config=new Configuration(new Environment("photos",new JdbcTransactionFactory(),source));
config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetResourcePhotoMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var repository=new FleetResourcePhotoRepository(session.getMapper(FleetResourcePhotoMapper.class));
assertThat(repository.current(Kind.DRIVER,7)).isNull();
var at=LocalDateTime.of(2026,10,26,0,0);
repository.append(new Stored("DRIVER",7,1,81,"first.png","image/png",3,"op",at));
repository.append(new Stored("DRIVER",7,2,82,"second.png","image/png",4,"op",at));
assertThat(repository.current(Kind.DRIVER,7).fileId()).isEqualTo(82);
assertThat(repository.file(Kind.DRIVER,7,81).fileName()).isEqualTo("first.png");
assertThat(repository.file(Kind.VEHICLE,7,81)).isNull();
assertThat(repository.file(Kind.DRIVER,8,81)).isNull();
assertThatThrownBy(() -> repository.append(new Stored("DRIVER",7,2,83,"conflict.png","image/png",3,"op",at))).isInstanceOf(RuntimeException.class);
assertThat(repository.current(Kind.DRIVER,7).fileId()).isEqualTo(82);
}
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.fleet.photo;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetResourcePhotoServiceTest {
final FleetResourcePhotoRepository repository=mock(FleetResourcePhotoRepository.class);
final FleetResourcePhotoGateway files=mock(FleetResourcePhotoGateway.class);
final DriverService drivers=mock(DriverService.class);
final FleetResourcePhotoService service=new FleetResourcePhotoService(repository,files,mock(VehicleRepository.class),drivers);
final Photo photo=new Photo("81","driver.png","image/png",3);
final MockMultipartFile upload=new MockMultipartFile("file","driver.png","image/png",new byte[]{1,2,3});
@Test void staleVersionCannotUploadAndConcurrentReplacementCannotOverwrite() {
when(repository.current(Kind.DRIVER,2)).thenReturn(new Stored("DRIVER",2,1,81,"driver.png","image/png",3,"op",LocalDateTime.now()));
assertThatThrownBy(() -> service.upload(Kind.DRIVER,2,0,"op",upload)).hasMessageContaining("已变化");
verifyNoInteractions(files);
when(files.upload(Kind.DRIVER,2,"op",upload)).thenReturn(photo);
doThrow(new DuplicateKeyException("version")).when(repository).append(any());
assertThatThrownBy(() -> service.upload(Kind.DRIVER,2,1,"op",upload)).hasMessageContaining("已变化");
}
@Test void fileFromAnotherProfileIsRejectedBeforeFileDownload() {
assertThatThrownBy(() -> service.download(Kind.DRIVER,2,81)).hasMessageContaining("不存在");
verifyNoInteractions(files);
}
@Test void firstUploadCreatesIndependentVersionAndDoesNotChangeDriverReview() {
when(files.upload(Kind.DRIVER,2,"op",upload)).thenReturn(photo);
assertThat(service.upload(Kind.DRIVER,2,0,"op",upload)).isEqualTo(new Detail(1,photo));
verify(drivers).detail(2,null);
verifyNoMoreInteractions(drivers);
verify(repository).append(argThat(row -> row.recordVersion()==1 && row.resourceId()==2 && row.fileId()==81));
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.fleet.photo;
import org.junit.jupiter.api.Test;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.*;
import static org.assertj.core.api.Assertions.*;
class HttpFleetResourcePhotoGatewayTest {
@Test void validatesResourceKindOwnerIdentityAndImageType() {
var data=new HttpFleetResourcePhotoGateway.Metadata(81L,0L,"FLEET_DRIVER_PHOTO","7","photo.png","image/png",3L);
var envelope=new HttpFleetResourcePhotoGateway.Envelope("200",data);
assertThat(HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,7,81L).fileId()).isEqualTo("81");
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,VEHICLE,7,81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,8,81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,7,82L)).hasMessageContaining("不属于");
for(var invalid : new HttpFleetResourcePhotoGateway.Metadata[]{
new HttpFleetResourcePhotoGateway.Metadata(81L,1L,"FLEET_DRIVER_PHOTO","7","photo.png","image/png",3L),
new HttpFleetResourcePhotoGateway.Metadata(81L,0L,"FLEET_DRIVER_PHOTO","7","photo.pdf","application/pdf",3L)}) {
assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(new HttpFleetResourcePhotoGateway.Envelope("200",invalid),DRIVER,7,81L)).hasMessageContaining("不属于");
}
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.fleet.photo;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 使用真实方法鉴权代理验证资源类型不能借用另一类档案权限。 */
class PlatformFleetResourcePhotoControllerTest {
@Configuration @EnableMethodSecurity
static class Config {
@Bean FleetResourcePhotoService service() { return mock(FleetResourcePhotoService.class); }
@Bean PlatformFleetResourcePhotoController controller(FleetResourcePhotoService service) { return new PlatformFleetResourcePhotoController(service); }
}
@Test void listPermissionOnlyReadsItsOwnResourceKindAndCannotUpload() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(FleetResourcePhotoService.class);
var controller=context.getBean(PlatformFleetResourcePhotoController.class);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test","",List.of(new SimpleGrantedAuthority("platform:overseas-master-data:vehicle:list"))));
when(service.current(Kind.VEHICLE,7)).thenReturn(new Detail(0,null));
controller.current(Kind.VEHICLE,7);
verify(service).current(Kind.VEHICLE,7);
assertThatThrownBy(() -> controller.current(Kind.DRIVER,7)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.upload(Kind.VEHICLE,7,0,null,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.upload(Kind.DRIVER,7,0,null,null)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
}