diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 8990b1a2e..00e5d25b4 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -237,6 +237,8 @@ SELECT ## 海外排车与出库结构升级 +- 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。 + - 海外仓出库与派送数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。 - 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。 - 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index e9591329d..0ed4eb788 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1181,7 +1181,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', - waybill_no VARCHAR(128) NULL COMMENT '承运运单号', delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商', + waybill_no VARCHAR(128) NULL COMMENT '承运运单号', + bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', + documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照', + delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商', pickup_method VARCHAR(64) NULL COMMENT '提货方式', loading_type VARCHAR(64) NULL COMMENT '装车类型', vehicle_type VARCHAR(64) NULL COMMENT '车辆类型', vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机或车队名称', driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', appointment_account VARCHAR(128) NULL COMMENT '预约账号', seller_name VARCHAR(128) NULL COMMENT '卖家姓名', @@ -1202,6 +1205,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee ( amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id) ) ENGINE=InnoDB COMMENT='派送单费用明细'; +CREATE TABLE IF NOT EXISTS ll_delivery_document_revision ( + id BIGINT NOT NULL COMMENT '派送资料历史ID', + customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + version BIGINT NOT NULL COMMENT '保存成功时派送单版本', + idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希', + snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID', + created_at DATETIME(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY(id), + UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key), + UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version) +) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录'; + CREATE TABLE IF NOT EXISTS ll_delivery_operation ( id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 79b94491d..985848e9a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -41,6 +41,7 @@ | 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | | 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 | +| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载,不增加派送完成门禁或签名识别 | | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | diff --git a/lastleg-service/docs/delivery-node-handoff-20260909.md b/lastleg-service/docs/delivery-node-handoff-20260909.md new file mode 100644 index 000000000..d920d6161 --- /dev/null +++ b/lastleg-service/docs/delivery-node-handoff-20260909.md @@ -0,0 +1,48 @@ +# 派送节点交付与六按钮现状核查 + +范围:测试项目当前 `test` 分支;后端16派送业务。仅源码和离线测试,不代表真实环境已部署或人工验收通过。 + +## 状态错误结论 + +用户请求仅提交 `status=DELIVERING, remark=123`,缺少必传 `expectedVersion`,因此返回参数错误。后端 `CustomerVisitRequests.DeliveryStatus` 保留版本必填;列表/详情 `DeliveryRow` 已返回 `version/allowedActions`。当前 Admin 的编辑状态选项将 `DELIVERING` 标为“派送中”,`DELIVERED` 标为“派送完成”,提交代码遗漏版本;不能据用户口述把 DELIVERING 改成完成。 + +状态命令保持 `{status,expectedVersion,remark}`,不新增幂等头。`START_DELIVERY→DELIVERING` 记录首次派送时间;`COMPLETE_DELIVERY→DELIVERED` 记录签收时间、更新尾程子单状态并生成待回访。相同状态重放成功,不重复状态写入;非法回退及版本冲突拒绝。未新增资料齐全、签名识别或电子签署门禁。 + +## 三类资料最终契约 + +完整机器契约:`src/main/resources/openapi/delivery-documents-v1.yaml`。七类方案已撤销,旧类别不接受。 + +| 顺序与业务类别 | 显示名称 | File类型 | +|---|---|---| +| LOADING_PHOTO | 装车照片 | DELIVERY_LOADING_PHOTO | +| BOL | BOL文件(含签名) | DELIVERY_BOL | +| RECEIPT | 签收文件(POD签收/快递签收) | DELIVERY_RECEIPT | + +每类0至20个文件,沿用 File 任意格式且单文件非空、不超过50MiB规则,按有已保存文件的类别计数0/3至3/3。POD与快递签收合为同一类别,文件数量不改变类别计数。 + +- 读取/下载权限:`platform:overseas-warehouse:delivery:list`;上传/保存权限:`platform:overseas-warehouse:delivery:documents`。 +- GET/PUT `/delivery-orders/{deliveryNo}/documents?customerId=...`;PUT全量保存 `{expectedVersion,idempotencyKey,bolNo,documents:[{category,fileIds}]}`,遗漏类别清空。 +- 同一派送锁内先检查版本与全部文件归属,再原子写当前快照、增加派送版本、写不可变历史及操作日志。文件保存使用 File 的真实文件名、格式、大小、创建时间,不信任前端元数据。 +- 上传不绑定、不修改版本或摘要。未保存/取消弹框不落绑定。替换和删除仅改当前引用,已保存历史和物理对象保留。 +- 当前下载仅允许当前绑定文件;指定 `revisionVersion` 仅可下载该派送单对应历史版本已绑定文件。两者均复核客户、派送内部ID、类别和fileId。 +- BOL号独立保存,未建立与其他单据的权威关联,不按相同BOL文本或运单号复用其他文件。 +- 列表和详情行新增 `bolNo/documentSummary`,摘要从本地保存快照读取,不逐文件调用File。 + +## 六按钮现状 + +| 按钮 | 当前实现与权限 | 判定与边界 | +|---|---|---| +| 编辑节点 | `CustomerVisitController` 的 PUT `/delivery-orders/{deliveryNo}/status`、POST `/complete`;`delivery:status`;版本与状态机 | 已实现状态命令,前端漏版本交Admin修复。当前策略只校验派送状态,未联查出库实物门禁;若“编辑节点”意为补录节点时间或修改完成前置规则,需进一步确认,不自动实现 | +| 打印BOL | 派送Controller未发现生成打印接口;Print现有海运提单确认件不能直接证明派送BOL实现 | 未实现派送专属生成链。上传已签名BOL及下载不等于生成打印;模板、字段、签名来源需确认 | +| 详情 | GET `/delivery-orders/{deliveryNo}?customerId=...`,`delivery:list`,返回派送行/货物/费用/操作历史 | 已实现,真实环境待验收 | +| 取消 | 通用状态仅允许WAIT_DELIVERY→CANCELLED,更新派送状态/版本/日志。另出库 `/outbound-orders/{outboundNo}/cancel` 属出库专项,校验未执行并取消关联派送、PDA任务和明细 | 部分实现。单改派送状态不等于撤销履约关联;出库取消还会将尾程主状态写RECEIVED,已报测试总控。需确认派送取消与出库/排车的业务关系,不能擅自互相替代 | +| 编辑车队 | 派送创建保存carrier/driver/vehicle快照,未发现派送单独立维护接口 | 未实现;可改状态、是否联动排车和来源主档规则需确认 | +| 标记退货 | `ExceptionOrderController` `/return-inbound-options`、`/mark-return`;`delivery:return`。从原出库明细选择,冻结可退上限,幂等生成异常;异常处理使用version/幂等与证据 | 已实现后端。退回海外仓必须先按来源恢复实收库存,再同事务完成异常并置派送RETURNED;不能直接改派送状态代替库存。Admin旧自由SKU入参交其收敛 | + +## 验证与部署前提 + +- 本次定向69项通过:包含资料版本/重放/异请求冲突、客户/单据/类别/fileId错配、文件计数与保存后重读、未保存下载拒绝、历史解绑下载、旧枚举拒绝、嵌套校验、权限分离、状态开始/完成/重放、现有出库/退货和SQL合同;只读用户的详情/重放/历史不返回管理动作,按当前授权而非历史权限裁剪。 +- Mapper扫描通过。AGENTS索引检查有两项既存柜费契约缺口:金额守恒、单行拒绝处理分摊组,未修改该域。 +- 模块基线和获准的海外统一入口同步 `ll_delivery_order.bol_no/documents_json`、`ll_delivery_document_revision`。只维护当前初始化定义,没有旧类别迁移或清理脚本;既有运行库应用方式交测试总控按环境授权处理。 +- 单元测试验证保存前全部校验无部分写入和快照重读;真实MySQL事务故障注入、跨服务真实上传下载、页面刷新与人工截图尚未验收。 +- 配套最终三类File提交 `81b72992` 和IAM权限 `cde8acf1` 已进入 `test`;不能使用早期七类File提交作为最终类别合同。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentController.java new file mode 100644 index 000000000..54ed94ca9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentController.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents") +public class DeliveryDocumentController { + @Resource private DeliveryDocumentService service; + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId) { + scope(user, customerId); return ApiResponse.success(authorized(service.detail(customerId, deliveryNo))); + } + @PutMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId, @Valid @RequestBody Save request) { + scope(user, customerId); return ApiResponse.success(authorized(service.save(customerId, deliveryNo, operator(user), request))); + } + @PostMapping(value="/{category}/files", consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId, + @PathVariable DeliveryDocumentCategory category, @RequestPart("file") MultipartFile file) { + scope(user, customerId); return ApiResponse.success(service.upload(customerId, deliveryNo, category, operator(user), file)); + } + @GetMapping("/history") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId) { + scope(user, customerId); return ApiResponse.success(service.history(customerId, deliveryNo).stream() + .map(h -> new History(h.version(), h.operatorId(), h.savedAt(), authorized(h.snapshot()))).toList()); + } + @GetMapping("/{category}/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") + public ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @RequestParam String customerId, + @PathVariable DeliveryDocumentCategory category, @PathVariable long fileId, + @RequestParam(required=false) Long revisionVersion) { + scope(user, customerId); + DeliveryDocumentService.Download downloaded = service.download(customerId, deliveryNo, category, fileId, revisionVersion); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString()) + .body(downloaded.content()); + } + private static Detail authorized(Detail detail) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + boolean canManage = authentication != null && authentication.isAuthenticated() + && authentication.getAuthorities().stream().anyMatch(a -> OverseasPermissions.DELIVERY_DOCUMENTS.equals(a.getAuthority())); + return new Detail(detail.deliveryNo(), detail.customerId(), detail.version(), detail.bolNo(), + detail.uploadedCategoryCount(), detail.totalCategoryCount(), detail.categories(), + canManage ? detail.allowedActions() : List.of(), detail.replayed()); + } + private static void scope(AuthenticatedUser user, String customerId) { + if (user == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可管理派送资料"); + try { if (Long.parseLong(customerId) <= 0) throw new NumberFormatException(); } + catch (NumberFormatException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定有效客户"); } + } + private static String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : user.userId().toString(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java new file mode 100644 index 000000000..9d3e12abf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.delivery.dto; + +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.LocalDateTime; +import java.util.List; + +public final class DeliveryDocumentDtos { + private DeliveryDocumentDtos() { } + public record Save(@NotNull @Min(0) Long expectedVersion, + @NotBlank @Size(max=128) String idempotencyKey, + @Size(max=128) String bolNo, + @NotNull @Size(max=3) List<@NotNull @Valid Binding> documents) { } + public record Binding(@NotNull DeliveryDocumentCategory category, + @NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { } + public record File(Long fileId, String fileName, String contentType, long sizeBytes, + LocalDateTime uploadedAt) { } + public record Category(DeliveryDocumentCategory category, String status, int fileCount, List files) { } + public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { } + public record Summary(int uploadedCategoryCount, int totalCategoryCount, List categories) { } + public record Detail(String deliveryNo, String customerId, long version, String bolNo, + int uploadedCategoryCount, int totalCategoryCount, List categories, + List allowedActions, boolean replayed) { } + public record History(long version, String operatorId, LocalDateTime savedAt, Detail snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 9a4c5e39b..b0bfc0e9d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -24,7 +24,8 @@ public final class DeliveryResponses { int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, - String remark, LocalDateTime createdAt, long version, List allowedActions) {} + String remark, LocalDateTime createdAt, long version, List allowedActions, + String bolNo, DeliveryDocumentDtos.Summary documentSummary) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java new file mode 100644 index 000000000..1b3d5788d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.delivery.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_delivery_document_revision") +public class DeliveryDocumentRevision { + @TableId(type=IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long deliveryOrderId; + private Long version; + private String idempotencyKey; + private String requestHash; + private String snapshotJson; + private String operatorId; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index bd839ea81..5bf10042a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -25,6 +25,8 @@ public class DeliveryOrder { private String customerName; private String status; private String waybillNo; + private String bolNo; + private String documentsJson; private String deliveryMethod; private String carrierName; private String pickupMethod; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java new file mode 100644 index 000000000..a66622a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.delivery.gateway; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import org.springframework.web.multipart.MultipartFile; + +public interface DeliveryDocumentFileGateway { + File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, String operatorId, MultipartFile file); + File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId); + byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java new file mode 100644 index 000000000..ef04202c3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.delivery.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import jakarta.annotation.*; +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.time.LocalDateTime; +import java.util.Objects; + +@Component +public class HttpDeliveryDocumentFileGateway implements DeliveryDocumentFileGateway { + private static final String PATH = "/internal/file/v1/business-files/{customer}/{type}/{delivery}"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + @PostConstruct void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile()); + } + @Override public File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, + String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > 52428800L) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "派送资料须为非空且不超过50MiB的文件"); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取派送资料", e); } + Envelope response = client.post().uri(PATH, customerId, category.businessType(), deliveryId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); }) + .body(Envelope.class); + return verified(response, customerId, deliveryId, category, null); + } + @Override public File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) { + Envelope response = client.get().uri(PATH + "/{file}", customerId, category.businessType(), deliveryId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); }) + .body(Envelope.class); + return verified(response, customerId, deliveryId, category, fileId); + } + @Override public byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) { + return client.get().uri(PATH + "/{file}/content", customerId, category.businessType(), deliveryId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); }) + .body(byte[].class); + } + private static File verified(Envelope response, String customerId, long deliveryId, + DeliveryDocumentCategory category, Long expectedFileId) { + if (response == null) throw failure(HttpStatus.SERVICE_UNAVAILABLE); + if (!"200".equals(response.code())) throw new ResponseStatusException( + response.code() != null && response.code().startsWith("A04") ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "派送资料文件不可用或归属校验失败"); + Metadata file = response.data(); + if (file == null || file.fileId() == null || file.fileId() <= 0 || file.createdAt() == null + || file.fileName() == null || file.sizeBytes() == null || file.sizeBytes() <= 0 + || file.sizeBytes() > 52428800L || !Objects.equals(String.valueOf(file.tenantId()), customerId) + || !category.businessType().equals(file.businessType()) || !String.valueOf(deliveryId).equals(file.businessId()) + || (expectedFileId != null && !expectedFileId.equals(file.fileId()))) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件不属于当前客户、派送单或资料类别"); + return new File(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes(), file.createdAt()); + } + private static ResponseStatusException failure(HttpStatusCode status) { + return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "派送资料文件请求失败"); + } + private record Envelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes, LocalDateTime createdAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java new file mode 100644 index 000000000..b9f6462e9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryDocumentRevision; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface DeliveryDocumentRevisionMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} AND idempotency_key=#{key}") + DeliveryDocumentRevision findCommand(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, @Param("key") String key); + @Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} ORDER BY version DESC") + List history(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java new file mode 100644 index 000000000..8390588e7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.delivery.model; + +public enum DeliveryDocumentCategory { + LOADING_PHOTO("DELIVERY_LOADING_PHOTO"), BOL("DELIVERY_BOL"), RECEIPT("DELIVERY_RECEIPT"); + + private final String businessType; + DeliveryDocumentCategory(String businessType) { this.businessType = businessType; } + public String businessType() { return businessType; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java new file mode 100644 index 000000000..55316ee5d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentSnapshot.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; + +/** Saved metadata only; listing never calls File and never counts pending uploads. */ +public final class DeliveryDocumentSnapshot { + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules(); + private DeliveryDocumentSnapshot() { } + public static List categories(String json) { + if (json == null || json.isBlank()) return Arrays.stream(DeliveryDocumentCategory.values()) + .map(category -> new Category(category, "MISSING", 0, List.of())).toList(); + try { return JSON.readValue(json, new TypeReference>() { }); } + catch (JsonProcessingException e) { throw new IllegalStateException("派送资料快照损坏", e); } + } + public static Summary summary(String json) { + List categories = categories(json); + return new Summary((int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, + categories.stream().map(c -> new CategorySummary(c.category(), c.status(), c.fileCount())).toList()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java new file mode 100644 index 000000000..5a5982605 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -0,0 +1,128 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +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.nio.charset.StandardCharsets; +import java.security.*; +import java.time.LocalDateTime; +import java.util.*; + +@Service +public class DeliveryDocumentService { + @Resource private DeliveryOrderMapper orders; + @Resource private DeliveryDocumentRevisionMapper revisions; + @Resource private DeliveryOperationMapper operations; + @Resource private DeliveryDocumentFileGateway files; + @Resource private ObjectMapper objectMapper; + + public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + + public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, + String operatorId, MultipartFile file) { + DeliveryOrder order = require(customerId, deliveryNo); + return files.upload(customerId, order.getId(), category, operatorId, file); + } + + /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ + @Transactional + public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { + DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); + if (order == null) throw missing(); + String hash = hash(request); + DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey()); + if (replay != null) { + if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同"); + Detail saved = read(replay.getSnapshotJson()); + return new Detail(saved.deliveryNo(), saved.customerId(), saved.version(), saved.bolNo(), + saved.uploadedCategoryCount(), 3, saved.categories(), saved.allowedActions(), true); + } + if (!Objects.equals(version(order), request.expectedVersion())) throw conflict("派送单版本已变化,请刷新后重试"); + Map> bindings = new EnumMap<>(DeliveryDocumentCategory.class); + Set unique = new HashSet<>(); + for (Binding binding : request.documents()) { + if (bindings.containsKey(binding.category())) throw bad("资料类别不能重复"); + List metadata = new ArrayList<>(); + for (Long fileId : binding.fileIds()) { + if (!unique.add(fileId)) throw bad("同一文件不能重复绑定"); + metadata.add(files.metadata(customerId, order.getId(), binding.category(), fileId)); + } + bindings.put(binding.category(), List.copyOf(metadata)); + } + List categories = Arrays.stream(DeliveryDocumentCategory.values()).map(category -> { + List values = bindings.getOrDefault(category, List.of()); + return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values); + }).toList(); + LocalDateTime now = LocalDateTime.now(); + order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim()); + order.setDocumentsJson(write(categories)); + order.setVersion(version(order) + 1); + order.setUpdatedAt(now); + orders.updateById(order); + Detail result = detail(order, false); + DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); + revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); + revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result)); + revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision); + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); + operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); + operation.setCreatedAt(now); operations.insert(operation); + return result; + } + public List history(String customerId, String deliveryNo) { + DeliveryOrder order = require(customerId, deliveryNo); + return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), + revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList(); + } + public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) { + DeliveryOrder order = require(customerId, deliveryNo); + Detail snapshot = detail(order, false); + if (revisionVersion != null) { + snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) + .findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing); + } + DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category) + .flatMap(c -> c.files().stream()).filter(f -> f.fileId() == fileId).findFirst().orElseThrow(DeliveryDocumentService::missing); + files.metadata(customerId, order.getId(), category, fileId); + return new Download(file, files.download(customerId, order.getId(), category, fileId)); + } + public record Download(DeliveryDocumentDtos.File file, byte[] content) { } + private DeliveryOrder require(String customerId, String deliveryNo) { + DeliveryOrder order = orders.findByNo(customerId, deliveryNo); + if (order == null) throw missing(); + return order; + } + private Detail detail(DeliveryOrder order, boolean replayed) { + List categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson()); + return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(), + (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); + } + private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); } + } + private Detail read(String value) { + try { return objectMapper.readValue(value, Detail.class); } + catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); } + } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 01825ae80..14bc48360 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -614,7 +614,7 @@ public class DeliveryTransferService { candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), value(candidate.getAvailablePackages())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 9f0025625..5a4efd302 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -22,6 +22,7 @@ public final class OverseasPermissions { public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list"; public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return"; public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status"; + public static final String DELIVERY_DOCUMENTS = "platform:overseas-warehouse:delivery:documents"; public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list"; public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit"; public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel"; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2712d3cbc..5480c02c4 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -260,6 +260,8 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( customer_name VARCHAR(255) NULL COMMENT '客户名称快照', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', + bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识', + documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照', delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商', pickup_method VARCHAR(64) NULL COMMENT '提货方式', @@ -306,6 +308,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee ( amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id) ) ENGINE=InnoDB COMMENT='派送单费用明细'; +CREATE TABLE IF NOT EXISTS ll_delivery_document_revision ( + id BIGINT NOT NULL COMMENT '派送资料历史ID', + customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + version BIGINT NOT NULL COMMENT '保存成功时派送单版本', + idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希', + snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID', + created_at DATETIME(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY(id), + UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key), + UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version) +) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录'; + CREATE TABLE IF NOT EXISTS ll_delivery_operation ( id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml new file mode 100644 index 000000000..9bad68cfb --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -0,0 +1,180 @@ +openapi: 3.0.3 +info: + title: 派送单三类资料 + version: 1.0.0 + description: | + 所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。 + GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 + 状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。 + START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 + 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 +security: [{ bearerAuth: [] }] +paths: + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + get: + summary: 查询已保存三类资料 + description: 权限 platform:overseas-warehouse:delivery:list;空资料仍返回全部三类MISSING。上传未保存不计入摘要。 + responses: + '200': { description: 当前快照, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } } + put: + summary: 原子保存全部类别绑定与BOL号 + description: | + 权限 platform:overseas-warehouse:delivery:documents。documents为全量快照,遗漏类别清空;Admin须提交完整三类。 + 同一派送单锁下校验expectedVersion、全部文件客户/派送单/类别归属后一起提交,任一失败不写任何绑定。 + 保存增加派送version,与状态修改共享版本;幂等键按客户+派送单限定,同键同请求重放原结果、异请求冲突。 + 替换/删除通过修改fileIds并保存表达,只解除当前绑定,历史对象和版本快照不可变保留。 + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/Save' } } } + responses: + '200': { description: 保存结果或统一业务错误, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + - { $ref: '#/components/parameters/Category' } + post: + summary: 上传一个待绑定文件 + description: | + 权限 platform:overseas-warehouse:delivery:documents。FormData唯一file,无额外幂等头。 + File沿用ARBITRARY任意格式、非空且不超过52428800字节(50MiB),每类别最多20个文件由保存校验。 + 上传仅产生不可变对象,不修改派送version或摘要;取消弹框/未保存不落绑定,不物理删除File对象。 + requestBody: + required: true + content: + multipart/form-data: + schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } + responses: + '200': + description: 单文件权威元数据 + content: + application/json: + schema: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: { data: { $ref: '#/components/schemas/File' } } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/history: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + get: + summary: 查看不可变资料保存历史 + description: 权限 platform:overseas-warehouse:delivery:list;版本倒序,仅当前客户和派送单。 + responses: + '200': + description: 历史快照 + content: + application/json: + schema: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: + data: + type: array + items: + type: object + properties: + version: { type: integer, format: int64 } + operatorId: { type: string } + savedAt: { type: string, format: date-time } + snapshot: { $ref: '#/components/schemas/Detail' } + /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files/{fileId}/download: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { $ref: '#/components/parameters/CustomerId' } + - { $ref: '#/components/parameters/Category' } + - { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { in: query, name: revisionVersion, required: false, schema: { type: integer, format: int64, minimum: 1 } } + get: + summary: 下载当前或指定保存版本已绑定文件 + description: 权限 platform:overseas-warehouse:delivery:list;省略revisionVersion只允许当前绑定。历史版本仍严格校验客户/单据/类别。未保存或跨单文件拒绝;附件响应no-store/nosniff。 + responses: + '200': { description: 文件内容, content: { application/octet-stream: { schema: { type: string, format: binary } } } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + DeliveryNo: { in: path, name: deliveryNo, required: true, schema: { type: string } } + CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } } + schemas: + CategoryCode: + type: string + enum: [LOADING_PHOTO, BOL, RECEIPT] + description: 依次为装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)。BOL签名由用户提供,不进行OCR或电子签署验证。 + Save: + type: object + required: [expectedVersion, idempotencyKey, documents] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + bolNo: { type: string, nullable: true, maxLength: 128, description: 不作为文件复用或归属匹配依据;空值清空 } + documents: + type: array + maxItems: 3 + items: + type: object + required: [category, fileIds] + properties: + category: { $ref: '#/components/schemas/CategoryCode' } + fileIds: { type: array, maxItems: 20, uniqueItems: true, items: { type: string, pattern: '^[1-9][0-9]*$' } } + File: + type: object + required: [fileId, fileName, sizeBytes, uploadedAt] + properties: + fileId: { type: string, pattern: '^[1-9][0-9]*$' } + fileName: { type: string } + contentType: { type: string, nullable: true } + sizeBytes: { type: integer, format: int64, minimum: 1, maximum: 52428800 } + uploadedAt: { type: string, format: date-time, description: File持久化的真实创建时间 } + CategorySummary: + type: object + required: [category, status, fileCount] + properties: + category: { $ref: '#/components/schemas/CategoryCode' } + status: { type: string, enum: [UPLOADED, MISSING] } + fileCount: { type: integer, minimum: 0, maximum: 20 } + DocumentSummary: + type: object + required: [uploadedCategoryCount, totalCategoryCount, categories] + properties: + uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 } + totalCategoryCount: { type: integer, enum: [3] } + categories: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/CategorySummary' } } + Detail: + type: object + required: [deliveryNo, customerId, version, uploadedCategoryCount, totalCategoryCount, categories, allowedActions, replayed] + properties: + deliveryNo: { type: string } + customerId: { type: string } + version: { type: integer, format: int64 } + bolNo: { type: string, nullable: true } + uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 } + totalCategoryCount: { type: integer, enum: [3] } + categories: + type: array + minItems: 3 + maxItems: 3 + items: + allOf: + - { $ref: '#/components/schemas/CategorySummary' } + - type: object + required: [files] + properties: { files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } } + allowedActions: { type: array, items: { type: string, enum: [MANAGE_DOCUMENTS] }, description: 返回时按当前认证授权裁剪;只读用户为空,历史和幂等重放也不能沿用旧权限 } + replayed: { type: boolean } + Envelope: + type: object + properties: + code: { type: string } + message: { type: string } + DetailResponse: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: { data: { $ref: '#/components/schemas/Detail' } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index a24f08c19..29d324ea6 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1,6 +1,7 @@ openapi: 3.0.3 info: title: Lastleg Overseas Business API + description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 version: 1.0.0 paths: /api/platform/v1/last-leg/inventory/balances/{balanceId}: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java index a2bb0717d..b93b9a0ce 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -107,6 +107,30 @@ class CustomerVisitServiceTest { assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); } + @Test + void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() { + DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + var started = service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERING", 0L, "开始派送")); + assertThat(started.deliveryStatus()).isEqualTo("DELIVERING"); + assertThat(delivery.getVersion()).isEqualTo(1); + assertThat(delivery.getDispatchedAt()).isNotNull(); + assertThat(delivery.getSignedAt()).isNull(); + verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class)); + CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); + when(visits.findByDelivery("1001", 11L)).thenReturn(visit); + var completed = service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)); + assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED"); + assertThat(delivery.getSignedAt()).isNotNull(); + assertThat(delivery.getVersion()).isEqualTo(2); + assertThat(completed.visitId()).isEqualTo(33); + assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue(); + verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery); + } + private DeliveryOrder delivery(String status) { DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(11L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java new file mode 100644 index 000000000..a5fe1a2a6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.controller.DeliveryDocumentController; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.util.*; +import java.nio.file.*; +import static org.assertj.core.api.Assertions.*; + +class DeliveryDocumentContractTest { + @Test void missingVersionNestedNullIdsAndTooManyFilesAreRejected() { + try(var factory=Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + assertThat(validator.validate(new CustomerVisitRequests.DeliveryStatus("DELIVERING",null,"123"))) + .anyMatch(v->v.getPropertyPath().toString().equals("expectedVersion")); + assertThat(validator.validate(new Save(null,"key",null,List.of()))).isNotEmpty(); + assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL, + Arrays.asList((Long)null)))))).isNotEmpty(); + assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL, + java.util.stream.LongStream.rangeClosed(1,21).boxed().toList()))))).isNotEmpty(); + } + } + @Test void removedCategoriesAreNotAcceptedAndOnlyThreeRemainInOrder() { + assertThat(DeliveryDocumentCategory.values()).containsExactly(DeliveryDocumentCategory.LOADING_PHOTO, + DeliveryDocumentCategory.BOL, DeliveryDocumentCategory.RECEIPT); + for(String category:List.of("POD","EXPRESS_RECEIPT","CUSTOMER_DOCUMENT","BILL_OF_LADING","DELIVERY_PHOTO")) { + assertThatThrownBy(()->new ObjectMapper().readValue("\""+category+"\"",DeliveryDocumentCategory.class)) + .isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class); + } + } + @Test void readDownloadAndWritePermissionsAreSeparate() { + for(var method:DeliveryDocumentController.class.getDeclaredMethods()) { + if(!Set.of("detail","history","download","save","upload").contains(method.getName())) continue; + String suffix=Set.of("save","upload").contains(method.getName())?"documents":"list"; + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:overseas-warehouse:delivery:"+suffix+"')"); + } + } + @Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception { + String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision ("; + String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1); + String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1); + assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+","")); + assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index a7a4680fe..609952846 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -66,6 +66,7 @@ class DeliveryLongIdJsonContractTest { DELIVERY_ID, null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, - 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of()); + 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java new file mode 100644 index 000000000..69d45d438 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeliveryDocumentControllerTest { + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + @Test void readonlyUserGetsNoWriteActionsInCurrentReplayOrHistory() throws Exception { verifyActions(false); } + @Test void currentWritePermissionRetainsManagementAction() throws Exception { verifyActions(true); } + private void verifyActions(boolean writer) throws Exception { + var service=mock(DeliveryDocumentService.class); + var controller=new DeliveryDocumentController(); ReflectionTestUtils.setField(controller,"service",service); + var user=new AuthenticatedUser(9L,0L,null,null,"reader","PLATFORM","WEB",Set.of()); + var authorities=writer?List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_DOCUMENTS)): + List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_LIST)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,authorities)); + var snapshot=new Detail("DL-1","100",1,null,0,3,List.of(),List.of("MANAGE_DOCUMENTS"),true); + when(service.detail("100","DL-1")).thenReturn(snapshot); + when(service.history("100","DL-1")).thenReturn(List.of(new History(1,"9",LocalDateTime.now(),snapshot))); + var request=new Save(0L,"key",null,List.of()); + when(service.save("100","DL-1","9",request)).thenReturn(snapshot); + var json=new ObjectMapper().findAndRegisterModules(); + assertThat(json.valueToTree(controller.detail(user,"DL-1","100")).at("/data/allowedActions").size()).isEqualTo(writer?1:0); + assertThat(json.valueToTree(controller.save(user,"DL-1","100",request)).at("/data/allowedActions").size()).isEqualTo(writer?1:0); + assertThat(json.valueToTree(controller.history(user,"DL-1","100")).at("/data/0/snapshot/allowedActions").size()).isEqualTo(writer?1:0); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java new file mode 100644 index 000000000..e30d5652a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.delivery.gateway; + +import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class HttpDeliveryDocumentFileGatewayTest { + HttpDeliveryDocumentFileGateway gateway; + MockRestServiceServer server; + @BeforeEach void setUp() { + var builder=RestClient.builder().baseUrl("http://file.test"); + server=MockRestServiceServer.bindTo(builder).build(); + gateway=new HttpDeliveryDocumentFileGateway(); + ReflectionTestUtils.setField(gateway,"client",builder.build()); + ReflectionTestUtils.setField(gateway,"internalToken","test-service-token"); + } + @Test void validatesExactOwnershipAndUsesFileCreationTime() { + expect(metadata("100","10","DELIVERY_BOL","11")); + var file=gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11); + assertThat(file.fileId()).isEqualTo(11); + assertThat(file.uploadedAt().toString()).isEqualTo("2026-09-09T08:00"); + server.verify(); + } + @ParameterizedTest + @ValueSource(strings={"customer","delivery","category","file"}) + void rejectsCrossCustomerCrossDeliveryWrongCategoryAndWrongFile(String mismatch) { + expect(metadata(mismatch.equals("customer")?"200":"100",mismatch.equals("delivery")?"20":"10", + mismatch.equals("category")?"DELIVERY_RECEIPT":"DELIVERY_BOL",mismatch.equals("file")?"12":"11")); + assertThatThrownBy(()->gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11)) + .hasMessageContaining("不属于"); + server.verify(); + } + private void expect(String json) { + server.expect(requestTo("http://file.test/internal/file/v1/business-files/100/DELIVERY_BOL/10/11")) + .andExpect(header("X-Internal-Service-Token","test-service-token")) + .andRespond(withSuccess(json,MediaType.APPLICATION_JSON)); + } + private String metadata(String customer,String delivery,String type,String file) { + return "{\"code\":\"200\",\"data\":{\"fileId\":"+file+",\"tenantId\":"+customer+ + ",\"businessType\":\""+type+"\",\"businessId\":\""+delivery+"\",\"fileName\":\"test.pdf\","+ + "\"contentType\":\"application/pdf\",\"sizeBytes\":10,\"createdAt\":\"2026-09-09T08:00:00\"}}"; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java new file mode 100644 index 000000000..c99dc56cb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*; + +@ExtendWith(MockitoExtension.class) +class DeliveryDocumentServiceTest { + @Mock DeliveryOrderMapper orders; + @Mock DeliveryDocumentRevisionMapper revisions; + @Mock DeliveryOperationMapper operations; + @Mock DeliveryDocumentFileGateway files; + DeliveryDocumentService service; + DeliveryOrder order; + @BeforeEach void setUp() { + service = new DeliveryDocumentService(); + ReflectionTestUtils.setField(service,"orders",orders); + ReflectionTestUtils.setField(service,"revisions",revisions); + ReflectionTestUtils.setField(service,"operations",operations); + ReflectionTestUtils.setField(service,"files",files); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100"); + order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED"); + } + @Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + when(files.metadata("100",10,BOL,12)).thenReturn(file(12)); + var result=service.save("100","DL-1","9",request(0,"save",List.of(new Binding(BOL,List.of(11L,12L))))); + assertThat(result.uploadedCategoryCount()).isEqualTo(1); + assertThat(result.categories()).hasSize(3); + assertThat(result.categories().get(1).fileCount()).isEqualTo(2); + assertThat(result.version()).isEqualTo(1); + assertThat(result.categories().get(1).files().get(0).uploadedAt()).isEqualTo(file(11).uploadedAt()); + when(orders.findByNo("100","DL-1")).thenReturn(order); + assertThat(service.detail("100","DL-1")).isEqualTo(result); + assertThat(DeliveryDocumentSnapshot.summary(order.getDocumentsJson()).uploadedCategoryCount()).isEqualTo(1); + verify(revisions).insert(any(DeliveryDocumentRevision.class)); verify(operations).insert(any(DeliveryOperation.class)); + } + @Test void staleVersionDoesNotValidateFilesOrWrite() { + order.setVersion(2L); when(orders.lockByNo("100","DL-1")).thenReturn(order); + assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"stale",List.of()))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本"); + verifyNoInteractions(files,operations); verify(orders,never()).updateById(any(DeliveryOrder.class)); + verify(revisions,never()).insert(any(DeliveryDocumentRevision.class)); + } + @Test void anyInvalidOwnershipRejectsWholeSnapshotBeforeMutation() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + when(files.metadata("100",10,RECEIPT,12)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"归属错误")); + assertThatThrownBy(()->service.save("100","DL-1","9",request(0,"bad",List.of( + new Binding(BOL,List.of(11L)),new Binding(RECEIPT,List.of(12L)))))).hasMessageContaining("归属"); + assertThat(order.getDocumentsJson()).isNull(); assertThat(order.getVersion()).isZero(); + verify(orders,never()).updateById(any(DeliveryOrder.class)); verifyNoInteractions(operations); + verify(revisions,never()).insert(any(DeliveryDocumentRevision.class)); + } + @Test void exactReplayDoesNotWriteOrRevalidateAndChangedPayloadConflicts() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + var request=request(0,"same",List.of()); + var first=service.save("100","DL-1","9",request); + ArgumentCaptor saved=ArgumentCaptor.forClass(DeliveryDocumentRevision.class); + verify(revisions).insert(saved.capture()); + when(revisions.findCommand("100",10,"same")).thenReturn(saved.getValue()); + assertThat(service.save("100","DL-1","9",request).replayed()).isTrue(); + assertThat(first.version()).isEqualTo(1); + verify(orders,times(1)).updateById(any(DeliveryOrder.class)); + assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"same",List.of()))) + .hasMessageContaining("幂等键"); + } + @Test void replacementRemovalPreservesHistoryAndCurrentDownloadRejectsRemovedFile() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,BOL,11)).thenReturn(file(11)); + service.save("100","DL-1","9",request(0,"first",List.of(new Binding(BOL,List.of(11L))))); + ArgumentCaptor first=ArgumentCaptor.forClass(DeliveryDocumentRevision.class); + verify(revisions).insert(first.capture()); + assertThat(service.save("100","DL-1","9",request(1,"clear",List.of())).uploadedCategoryCount()).isZero(); + when(orders.findByNo("100","DL-1")).thenReturn(order); + assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class); + when(revisions.history("100",10)).thenReturn(List.of(first.getValue())); + when(files.download("100",10,BOL,11)).thenReturn(new byte[]{1}); + assertThat(service.download("100","DL-1",BOL,11,1L).content()).containsExactly((byte)1); + } + @Test void uploadWithoutSaveNeverChangesSummaryAndUnboundFileCannotDownload() { + when(orders.findByNo("100","DL-1")).thenReturn(order); + var input=new MockMultipartFile("file","a.pdf","application/pdf",new byte[]{1}); + when(files.upload("100",10,BOL,"9",input)).thenReturn(file(11)); + assertThat(service.upload("100","DL-1",BOL,"9",input).fileId()).isEqualTo(11); + assertThat(service.detail("100","DL-1").uploadedCategoryCount()).isZero(); + assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(revisions,operations); verify(orders,never()).updateById(any(DeliveryOrder.class)); + } + @Test void anotherCustomerOrDeliveryCannotLoadOrBindFiles() { + assertThatThrownBy(()->service.detail("200","DL-1")).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.save("100","DL-2","9",request(0,"wrong",List.of()))) + .isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(files,revisions,operations); + } + @Test void allThreeCategoriesCountToThreeRegardlessOfFileMultiplicity() { + when(orders.lockByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,LOADING_PHOTO,11)).thenReturn(file(11)); + when(files.metadata("100",10,BOL,12)).thenReturn(file(12)); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + var result=service.save("100","DL-1","9",request(0,"all",List.of( + new Binding(LOADING_PHOTO,List.of(11L)),new Binding(BOL,List.of(12L)),new Binding(RECEIPT,List.of(13L))))); + assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3); + assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED")); + } + private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } + private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } +}