diff --git a/file/AGENTS.md b/file/AGENTS.md index cfbc64dcb..c2d123355 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -22,7 +22,7 @@ | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | | 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | -| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | +| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,上传仅非空 JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | | 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 | | 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index b777e60a2..c19b10891 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -9,7 +9,7 @@ import java.util.Arrays; 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_DRIVER_LICENSE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/openapi/fleet-driver-license-v1.yaml b/file/src/main/resources/openapi/fleet-driver-license-v1.yaml index 2d88200e9..342ef9b6b 100644 --- a/file/src/main/resources/openapi/fleet-driver-license-v1.yaml +++ b/file/src/main/resources/openapi/fleet-driver-license-v1.yaml @@ -5,7 +5,7 @@ info: description: >- 复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、 businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性; - File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。 + File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。上传最大10MiB,仅JPG/PNG。 上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。 security: - InternalServiceToken: [] diff --git a/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java b/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java index 5664cf19f..4de1c7bb5 100644 --- a/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java @@ -24,15 +24,22 @@ class DriverLicenseFileTest { assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class); } - @Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception { + /** 驾驶证上传接受有效 JPG/PNG,其他格式和伪装的图片均被拒绝。 */ + @Test void storesImagesAndRejectsPdfWrongMimeAndSpoofedImages() throws Exception { var storage = storage(); var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes()); - var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE); - assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes()); + for (var format : new String[]{"jpg", "png"}) { + var type = format.equals("jpg") ? "image/jpeg" : "image/png"; + var file = new MockMultipartFile("file", "license." + format, type, image(format)); + var result = storage.store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE); + assertThat(storage.read(result.objectKey())).isEqualTo(file.getBytes()); + } + assertThatThrownBy(() -> storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持JPG、PNG"); assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) .isInstanceOf(BizException.class).hasMessageContaining("仅支持"); - assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) - .isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF"); + assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.png", "image/png", pdf.getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) + .isInstanceOf(BizException.class).hasMessageContaining("图片"); } @Test void rejectsMoreThanTenMibBeforeReadingBytes() { @@ -43,6 +50,14 @@ class DriverLicenseFileTest { .isInstanceOf(BizException.class).hasMessageContaining("10MB"); } + /** 生成可解码的图片,覆盖文件格式与实际内容一致的上传路径。 */ + private static byte[] image(String format) throws java.io.IOException { + var bytes = new java.io.ByteArrayOutputStream(); + var image = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB); + assertThat(javax.imageio.ImageIO.write(image, format, bytes)).isTrue(); + return bytes.toByteArray(); + } + private BusinessFileStorage storage() { var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java index 2e5dcd804..d29691dda 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java @@ -19,7 +19,8 @@ import java.util.Set; public class HttpDriverFileGateway implements DriverFileGateway { static final String TYPE = "FLEET_DRIVER_LICENSE"; static final long MAX_SIZE = 10L * 1024 * 1024; - private static final Set CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png"); + private static final Set READ_CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png"); + private static final Set UPLOAD_CONTENT_TYPES = Set.of("image/jpeg", "image/png"); private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}"; private final RestClient client; private final String token; @@ -31,10 +32,11 @@ public class HttpDriverFileGateway implements DriverFileGateway { this.token = token; } + /** 新上传的驾驶证只接受 JPG/PNG,读取历史附件继续按原文件元数据校验。 */ @Override public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) { - if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), ""))) - throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB"); + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !UPLOAD_CONTENT_TYPES.contains(Objects.toString(file.getContentType(), ""))) + throw bad("驾驶证附件须为非空的 JPG 或 PNG,最大 10MiB"); var body = new MultipartBodyBuilder(); try { body.part("file", new ByteArrayResource(file.getBytes()) { @@ -45,7 +47,9 @@ public class HttpDriverFileGateway implements DriverFileGateway { .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, driverId, null); + DriverModels.File uploaded = verified(result, driverId, null); + if (!UPLOAD_CONTENT_TYPES.contains(uploaded.contentType())) throw bad("驾驶证附件须为 JPG 或 PNG"); + return uploaded; } @Override @@ -72,7 +76,7 @@ public class HttpDriverFileGateway implements DriverFileGateway { if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) || !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId()) || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() - || !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null + || !READ_CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) throw bad("文件不属于当前司机或不是有效驾驶证附件"); return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index d1e1cb497..0c4d06f88 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -13,7 +13,8 @@ public final class ReceivingContainerResponses { List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean operable, boolean allPlansReady, String blockReason, - LocalDateTime plannedArrivalAt, boolean currentAccount) { + LocalDateTime plannedArrivalAt, boolean currentAccount, String cabinetNo) { + /** 保留不含接柜仓与柜次号的既有响应构造方式。 */ public Candidate(String sourceContainerBookingId, String containerNo, String status, int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, int planCount, int plannedInboundCodeCount, int pendingPlanCount, @@ -23,7 +24,7 @@ public final class ReceivingContainerResponses { this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, - receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount); + receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount, null); } } @@ -36,7 +37,8 @@ public final class ReceivingContainerResponses { LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, - boolean hasDifference, List inbounds) { + boolean hasDifference, List inbounds, String cabinetNo) { + /** 旧会话构造缺少接柜仓编码及柜次号时保持空值。 */ public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, @@ -44,9 +46,10 @@ public final class ReceivingContainerResponses { int pendingPackages, List inbounds) { this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages, - receivedPackages, pendingPackages, false, inbounds); + receivedPackages, pendingPackages, false, inbounds, null); } + /** 保留不含柜次号及国内照片的既有会话构造方式。 */ public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, @@ -54,7 +57,7 @@ public final class ReceivingContainerResponses { int receivedPackages, int pendingPackages, List inbounds) { this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt, - completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds); + completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds, null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java index 803fe4861..c6c6f8c27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java @@ -17,6 +17,21 @@ public interface ReceivingCandidateMapper { record AvailableOrder(Long orderId, String bookingId) { } + /** 按稳定柜次身份批量读取本库装柜事件中的正式柜次号,不从柜号或内部 ID 推导。 */ + @Select(""" + + """) + List cabinetNumbers(@Param("ids") List ids); + + record CabinetNumber(String bookingId, String cabinetNo) { } + /** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */ @Select("