From e49ad96cc468563cd9023a34e9dddc803d02e6c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 16:38:37 +0800 Subject: [PATCH] feat(fleet): attach scoped evidence to measured trip records --- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 8 +- .../file/service/BusinessFileStorage.java | 4 +- .../fleet-trip-operation-evidence-v1.yaml | 12 ++ .../TripOperationEvidenceFileTest.java | 79 ++++++++ lastleg-service/AGENTS.md | 2 +- .../HttpTripOperationsEvidenceGateway.java | 71 +++++++ ...latformTripOperationsRecordController.java | 21 +- .../TripOperationsEvidenceGateway.java | 9 + .../reporting/TripOperationsRecordModels.java | 13 +- .../TripOperationsRecordService.java | 44 +++- .../openapi/fleet-operations-v1.yaml | 190 +++++++++++++++++- ...HttpTripOperationsEvidenceGatewayTest.java | 25 +++ ...TripOperationsRecordAuthorizationTest.java | 35 ++++ .../TripOperationsRecordPersistenceTest.java | 47 ++++- scripts/README.md | 2 +- ...-company-cost-acceptance-contract.test.mjs | 9 + .../fleet-quotes-operations-contract.test.mjs | 6 +- .../fleet-quotes-operations.mjs | 4 +- scripts/fleet-company-cost-test-env.mjs | 5 +- scripts/synchronous-dependency-baseline.mjs | 15 ++ .../synchronous-dependency-contract.test.mjs | 9 +- 22 files changed, 584 insertions(+), 27 deletions(-) create mode 100644 file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 75e2f76ea..33f23e85c 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -20,6 +20,7 @@ | 海外异常订单证据 | 通用内部业务文件入口;固定 `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-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 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | 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 10a375cfd..7bc39d2e8 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -7,7 +7,8 @@ import java.util.Arrays; /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ enum BusinessFileCategory { - FLEET_DRIVER_LICENSE(false, FormatPolicy.LICENSE, 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), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -62,6 +63,9 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { + if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); + } if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); } @@ -105,7 +109,7 @@ enum BusinessFileCategory { return Long.toString(value); } - enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, LICENSE, IMAGE } + enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private static final class Limits { private static final long STANDARD = 10L * 1024 * 1024; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 11ee988ae..2b78410e0 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -66,8 +66,8 @@ public class BusinessFileStorage { } else { if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type)) throw bad("图片附件仅支持JPG、PNG"); - if (formatPolicy == BusinessFileCategory.FormatPolicy.LICENSE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) - throw bad("驾驶证附件仅支持PDF、JPG、PNG"); + if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF_IMAGE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) + throw bad("附件仅支持PDF、JPG、PNG"); extension = TYPES.get(type); if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); verifyContent(file, type); diff --git a/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml b/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml new file mode 100644 index 000000000..1f96cda87 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: 出车实测凭证归属契约 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。 + businessType固定FLEET_TRIP_OPERATION_EVIDENCE,tenantId固定0,businessId固定Lastleg车次内部主键。 + 凭证为选填;上传时只接受非空PDF/JPG/PNG,最大10MiB,校验MIME与内容签名。 + 文件按平台、业务类型、车次及文件ID隔离;Lastleg持有当前/历史实测版本的附件引用及读写权限。 + 上传不自动保存实测记录,也不变更出车、库存、费用或签收事实;历史对象不硬删除。 + Lastleg读取时先确认附件属于可见实测版本,再读取内容;不返回内部存储路径或匿名文件URL。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java new file mode 100644 index 000000000..0cac6cc4c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java @@ -0,0 +1,79 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TripOperationEvidenceFileTest { + @TempDir Path root; + private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE"; + + @Test void platformTripOwnershipIsRequiredAndDistinctFromOtherFleetFiles() { + var category = BusinessFileCategory.require(TYPE); + category.validateOwner(0); + for (long customer : new long[]{-1, 1, 900}) + assertThatThrownBy(() -> category.validateOwner(customer)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + assertThat(category.storageBusinessId("71")).isEqualTo("71"); + for (String id : new String[]{"0", "-1", "TRIP-71", "../71"}) + assertThatThrownBy(() -> category.storageBusinessId(id)).isInstanceOf(BizException.class); + assertThat(category).isNotEqualTo(BusinessFileCategory.FLEET_DRIVER_LICENSE) + .isNotEqualTo(BusinessFileCategory.FLEET_VEHICLE_RECORD_PHOTO); + } + + @Test void storesPdfAndImagesAndRejectsEmptySpoofedOrOversizeContent() throws Exception { + var storage = storage(); + var pdf = new MockMultipartFile("file", "里程凭证.pdf", "application/pdf", "%PDF-1.7\nmeasured".getBytes()); + var png = new MockMultipartFile("file", "油表.png", "image/png", image("png")); + var jpeg = new MockMultipartFile("file", "里程.jpg", "image/jpeg", image("jpeg")); + for (var file : new MockMultipartFile[]{pdf,png,jpeg}) { + var saved = storage.store(0,"71",file,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + } + for (var bad : new MockMultipartFile[]{ + new MockMultipartFile("file","empty.pdf","application/pdf",new byte[0]), + new MockMultipartFile("file","fake.pdf","application/pdf","text".getBytes()), + new MockMultipartFile("file","fake.png","image/png","text".getBytes()), + new MockMultipartFile("file","notes.txt","text/plain","text".getBytes())}) + assertThatThrownBy(() -> storage.store(0,"71",bad,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)).isInstanceOf(BizException.class); + MultipartFile oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); when(oversized.getSize()).thenReturn(10L*1024*1024+1); + assertThatThrownBy(() -> storage.store(0,"71",oversized,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized,never()).getInputStream(); + } + + @Test void uploadPreservesTripScopeAndWrongOwnerOrMissingBindingNeverReadsBytes() { + var mapper = mock(FileObjectMapper.class); var storage = storage(); + var service = new InternalBusinessFileService(); + ReflectionTestUtils.setField(service,"files",mapper); ReflectionTestUtils.setField(service,"storage",storage); + when(mapper.insert(any(FileObjectEntity.class))).thenAnswer(call->{((FileObjectEntity)call.getArgument(0)).setId(501L);return 1;}); + var upload = new MockMultipartFile("file","record.pdf","application/pdf","%PDF-1.7\nrecord".getBytes()); + var metadata = service.upload(0,TYPE,"71",7,upload); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); assertThat(metadata.businessType()).isEqualTo(TYPE); + assertThat(metadata.businessId()).isEqualTo("71"); assertThat(metadata.fileId()).isEqualTo(501L); + assertThatThrownBy(()->service.metadata(1,TYPE,"71",501)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + when(mapper.selectOne(any())).thenReturn(null); + assertThatThrownBy(()->service.download(0,TYPE,"72",501)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + + private BusinessFileStorage storage() { + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); + storage.initializeRoot(); return storage; + } + + private byte[] image(String format) throws java.io.IOException { + var bytes = new java.io.ByteArrayOutputStream(); + var image = new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_RGB); + assertThat(javax.imageio.ImageIO.write(image,format,bytes)).isTrue(); + return bytes.toByteArray(); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 3ce3b32a3..3424cec54 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -76,7 +76,7 @@ ## 海外仓全流程开发路由 - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 -- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java new file mode 100644 index 000000000..4e6d26004 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.reporting; + +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 HttpTripOperationsEvidenceGateway implements TripOperationsEvidenceGateway { + private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE"; + private static final String PATH = "/internal/file/v1/business-files/0/FLEET_TRIP_OPERATION_EVIDENCE/{trip}"; + private static final long MAX_SIZE = 10L * 1024 * 1024; + private final RestClient client; + private final String token; + public HttpTripOperationsEvidenceGateway(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; + } + @Override public TripOperationsRecordModels.EvidenceFile upload(long tripId, String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("实测凭证须为非空 PDF、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, tripId).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, tripId, null); + } + @Override public TripOperationsRecordModels.EvidenceFile metadata(long tripId, long fileId) { + return verified(client.get().uri(PATH + "/{file}", tripId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), tripId, fileId); + } + @Override public byte[] download(long tripId, long fileId) { + var response = client.get().uri(PATH + "/{file}/content", tripId, 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 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; + } + static TripOperationsRecordModels.EvidenceFile verified(Envelope result, long tripId, 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.equals(data.businessType()) || !Long.toString(tripId).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 TripOperationsRecordModels.EvidenceFile(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); + } + private static boolean image(String type) { return Set.of("application/pdf", "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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java index 9d764b8c1..4f1eb6bb8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java @@ -10,19 +10,38 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ContentDisposition; @RestController @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record") public class PlatformTripOperationsRecordController { private final TripOperationsRecordService service; public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;} + @PostMapping(value="/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@RequestPart("file") MultipartFile file){ + return ApiResponse.success(service.upload(tripNo,operator(user),file)); + } + @GetMapping("/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ResponseEntity download(@PathVariable String tripNo,@PathVariable @Positive long fileId){ + var value=service.download(tripNo,fileId); + return ResponseEntity.ok().contentType(MediaType.parseMediaType(value.file().contentType())) + .header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.inline().filename(value.file().fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()).body(value.content()); + } @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") public ApiResponse get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));} @GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") public ApiResponse> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));} @PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){ + return ApiResponse.success(service.save(tripNo,operator(user),request)); + } + private static String operator(AuthenticatedUser user){ if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录"); - return ApiResponse.success(service.save(tripNo,user.userId().toString(),request)); + return user.userId().toString(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java new file mode 100644 index 000000000..81abe6d1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.web.multipart.MultipartFile; + +public interface TripOperationsEvidenceGateway { + TripOperationsRecordModels.EvidenceFile upload(long tripId,String operator,MultipartFile file); + TripOperationsRecordModels.EvidenceFile metadata(long tripId,long fileId); + byte[] download(long tripId,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java index f82624ace..b252e4443 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java @@ -4,16 +4,25 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.Instant; +import java.util.List; public final class TripOperationsRecordModels { private TripOperationsRecordModels() { } public record Details( @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles, @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres, - @Size(max=1000) String remark) { } + @Size(max=1000) String remark, List evidenceFiles) { + public Details { evidenceFiles=evidenceFiles==null?List.of():List.copyOf(evidenceFiles); } + } + public record EvidenceFile(String fileId,String fileName,String contentType,long sizeBytes) { } + public record Input(@DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualDistanceMiles, + @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualFuelLitres, + @Size(max=1000) String remark,@Size(max=20) List<@NotNull @Positive Long> evidenceFileIds) { + public Input { evidenceFileIds=evidenceFileIds==null?List.of():List.copyOf(evidenceFileIds); } + } public record Save(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min=8,max=128) String idempotencyKey, - @NotNull @Valid Details details, @Size(max=1000) String reason) { } + @NotNull @Valid Input details, @Size(max=1000) String reason) { } public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { } public record History(long recordVersion, String reason, Instant occurredAt, Details details) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java index 7b6d9ad21..dfe7f7873 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java @@ -13,13 +13,40 @@ import java.nio.charset.StandardCharsets; import java.security.*; import java.time.*; import java.util.HexFormat; +import java.util.List; +import java.util.HashSet; +import com.baiyue.lastleg.fleet.trip.FleetTripMapper; +import org.springframework.web.multipart.MultipartFile; /** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */ @Service public class TripOperationsRecordService { private final TripOperationsRecordRepository repository; private final ObjectMapper json; - public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json) { this.repository=repository;this.json=json; } + private final FleetTripMapper trips; + private final TripOperationsEvidenceGateway files; + public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json,FleetTripMapper trips,TripOperationsEvidenceGateway files) { this.repository=repository;this.json=json;this.trips=trips;this.files=files; } + @Transactional(readOnly=true) + public TripOperationsRecordModels.EvidenceFile upload(String tripNo,String operator,MultipartFile file) { + return files.upload(evidenceTripId(tripNo),operator,file); + } + public record Download(TripOperationsRecordModels.EvidenceFile file,byte[] content) { } + @Transactional(readOnly=true) + public Download download(String tripNo,long fileId) { + long tripId=evidenceTripId(tripNo); + boolean referenced=false; + long total=repository.count(tripNo); + for(int page=1;(long)(page-1)*100read(row.details()).evidenceFiles().stream().anyMatch(file->Long.toString(fileId).equals(file.fileId()))); + } + if(!referenced)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"凭证尚未保存到本车实测记录或历史版本"); + return new Download(files.metadata(tripId,fileId),files.download(tripId,fileId)); + } + private long evidenceTripId(String tripNo) { + var trip=trips.findByNo(tripNo); + if(trip==null || trip.getDepartedAt()==null)throw conflict("仅实际已发车的车次可维护实测凭证"); + return trip.getId(); + } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public TripOperationsRecordModels.Record get(String tripNo) { requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo)); @@ -33,21 +60,28 @@ public class TripOperationsRecordService { @Transactional(isolation=Isolation.READ_COMMITTED) public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) { requireDeparture(tripNo,true); - var d=request.details();var details=new TripOperationsRecordModels.Details(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark())); + var d=request.details(); + if(d.evidenceFileIds().size()>20 || new HashSet<>(d.evidenceFileIds()).size()!=d.evidenceFileIds().size() + || d.evidenceFileIds().stream().anyMatch(id->id==null||id<=0))throw conflict("每次最多绑定20个不同的实测凭证"); + var input=new TripOperationsRecordModels.Input(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark()),d.evidenceFileIds()); String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录"; - String contents=write(details);String hash=hash(request.recordVersion()+":"+contents+":"+reason); + String hash=hash(request.recordVersion()+":"+write(input)+":"+reason); var replay=repository.replay(tripNo,operator,request.idempotencyKey()); if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);} var previous=repository.latest(tripNo);long version=previous==null?0:previous.version(); if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正"); + long tripId=input.evidenceFileIds().isEmpty()?0:evidenceTripId(tripNo); + var evidence=input.evidenceFileIds().stream().map(fileId->files.metadata(tripId,fileId)).toList(); + var details=new TripOperationsRecordModels.Details(input.actualDistanceMiles(),input.actualFuelLitres(),input.remark(),evidence); + String contents=write(details); if(previous!=null && previous.details().equals(contents))return response(tripNo,previous); - if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null)return response(tripNo,null); + if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null && details.evidenceFiles().isEmpty())return response(tripNo,null); var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC)); repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row); } private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");} private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) { - return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null),null) + return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null,List.of()),null) :new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC)); } private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}} diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index 4f43b2d5c..aa4fb9ffa 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -320,7 +320,7 @@ } }, "put": { - "description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。", + "description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。;附件选填,最多20个,保存前验证平台0、FLEET_TRIP_OPERATION_EVIDENCE和本车trip.id归属;幂等键包含完整文件ID列表,成功重放返回原快照,移除当前引用保留历史。", "requestBody": { "required": true, "content": { @@ -338,7 +338,7 @@ "maxLength": 128 }, "details": { - "$ref": "#/components/schemas/Measured" + "$ref": "#/components/schemas/MeasuredInput" }, "reason": { "type": "string", @@ -480,6 +480,115 @@ } } } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/files": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "post": { + "summary": "上传选填实测凭证,不创建实测记录", + "description": "platform:fleet:operations:edit;仅平台0,实际已发车车次;FLEET_TRIP_OPERATION_EVIDENCE+trip.id,PDF/JPG/PNG,非空且最大10MiB;上传失败不创建或重建记录。", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "统一响应data为可信文件信息", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/OperationEvidenceFile" + } + } + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/files/{fileId}/content": { + "parameters": [ + { + "name": "tripNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fileId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "get": { + "summary": "按需读取本车实测凭证,历史引用继续可读", + "description": "platform:fleet:operations:list;仅已保存至本车当前或历史版本的引用可读;重验平台0/业务类型/车次内部ID/文件ID,no-store;不逐行预取。", + "responses": { + "200": { + "description": "PDF或图片原始内容", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "文件不可用或归属不符" + } + } + } } }, "components": { @@ -695,12 +804,19 @@ "type": "string", "nullable": true, "maxLength": 1000 + }, + "evidenceFiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OperationEvidenceFile" + } } }, "required": [ "actualDistanceMiles", "actualFuelLitres", - "remark" + "remark", + "evidenceFiles" ] }, "Destination": { @@ -918,6 +1034,74 @@ "unallocatedTrips", "totals" ] + }, + "MeasuredInput": { + "type": "object", + "properties": { + "actualDistanceMiles": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "actualFuelLitres": { + "type": "number", + "minimum": 0, + "nullable": true, + "multipleOf": 0.1 + }, + "remark": { + "type": "string", + "nullable": true, + "maxLength": 1000 + }, + "evidenceFileIds": { + "type": "array", + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "description": "选填,空数组合法;只提交本车已上传文件ID,保存重新核验归属" + } + }, + "required": [ + "actualDistanceMiles", + "actualFuelLitres", + "remark" + ] + }, + "OperationEvidenceFile": { + "type": "object", + "required": [ + "fileId", + "fileName", + "contentType", + "sizeBytes" + ], + "properties": { + "fileId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string", + "enum": [ + "application/pdf", + "image/jpeg", + "image/png" + ] + }, + "sizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + } + } } } }, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java new file mode 100644 index 000000000..f62806b07 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import static org.assertj.core.api.Assertions.*; + +class HttpTripOperationsEvidenceGatewayTest { + @Test void platformCategoryTripAndFileMustAllMatch() { + assertThat(HttpTripOperationsEvidenceGateway.verified(envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"application/pdf",8),71,81L).fileId()).isEqualTo("81"); + for(var value:java.util.List.of(envelope(9,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"image/png",8), + envelope(0,"FLEET_VEHICLE_RECORD_PHOTO","71",81,"image/png",8),envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","72",81,"image/png",8), + envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",82,"image/png",8),envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"text/html",8), + envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"image/png",10485761))) + assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.verified(value,71,81L)).hasMessageContaining("不属于"); + } + @Test void contentRejectsEnvelopesAndEmptyFilesButAcceptsPdfAndImages() { + assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes()))).hasMessageContaining("不可用"); + assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).body(new byte[0]))).hasMessageContaining("不可用"); + for(var type:java.util.List.of(MediaType.APPLICATION_PDF,MediaType.IMAGE_PNG,MediaType.IMAGE_JPEG)) + assertThat(HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(type).body(new byte[]{1}))).containsExactly(1); + } + private HttpTripOperationsEvidenceGateway.Envelope envelope(long owner,String type,String trip,long file,String mime,long size){ + return new HttpTripOperationsEvidenceGateway.Envelope("200",new HttpTripOperationsEvidenceGateway.Metadata(file,owner,type,trip,"meter.pdf",mime,size)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java new file mode 100644 index 000000000..86aa49c87 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.access.AccessDeniedException; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; + +class TripOperationsRecordAuthorizationTest { + @Test void evidenceReadsAndWritesUseSeparateOperationsPermissions() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformTripOperationsRecordController.class); + var service=context.getBean(TripOperationsRecordService.class); + when(service.download("TR-A",81)).thenReturn(new TripOperationsRecordService.Download(new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",1),new byte[]{1})); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reader","unused",List.of(new SimpleGrantedAuthority("platform:fleet:operations:list")))); + assertThat(controller.download("TR-A",81).getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThatThrownBy(()->controller.upload(null,"TR-A",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.save(null,"TR-A",null)).isInstanceOf(AccessDeniedException.class); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("writer","unused",List.of(new SimpleGrantedAuthority("platform:fleet:operations:edit")))); + assertThatThrownBy(()->controller.download("TR-A",81)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.upload(null,"TR-A",null)).hasMessageContaining("仅平台人员"); + verify(service).download("TR-A",81);verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + @Configuration @EnableMethodSecurity + static class Config { + @Bean TripOperationsRecordService service(){return mock(TripOperationsRecordService.class);} + @Bean PlatformTripOperationsRecordController controller(TripOperationsRecordService service){return new PlatformTripOperationsRecordController(service);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java index d56fa7759..fed6868e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java @@ -17,16 +17,20 @@ import static org.mockito.Mockito.*; class TripOperationsRecordPersistenceTest { private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service; + private com.baiyue.lastleg.fleet.trip.FleetTripMapper trips; + private TripOperationsEvidenceGateway files; @BeforeEach void setup() throws Exception { var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source); jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)"); jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)"); var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT")); - repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule())); + trips=mock(com.baiyue.lastleg.fleet.trip.FleetTripMapper.class);files=mock(TripOperationsEvidenceGateway.class); + var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setId(71L);trip.setTripNo("TR-A");trip.setDepartedAt(java.time.LocalDateTime.of(2026,10,1,0,0));when(trips.findByNo("TR-A")).thenReturn(trip); + repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule()),trips,files); var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy(); } - private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);} + private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Input(distance,fuel,null,java.util.List.of()),null);} @Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() { assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero(); assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车"); @@ -53,4 +57,43 @@ class TripOperationsRecordPersistenceTest { assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero(); } + private TripOperationsRecordModels.Save evidence(long version,String key,Long... ids){ + return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Input(null,null,null,java.util.List.of(ids)),null); + } + @Test void optionalEvidenceIsValidatedAndFrozenWithHistoryAndExactReplay() { + var file=new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",8); + when(files.metadata(71,81)).thenReturn(file); + var command=evidence(0,"attach-proof",81L);var saved=service.save("TR-A","1",command); + assertThat(saved.details().evidenceFiles()).containsExactly(file);assertThat(saved.recordVersion()).isEqualTo(1); + service.save("TR-A","1",evidence(1,"clear-proof")); + assertThat(service.get("TR-A").details().evidenceFiles()).isEmpty(); + reset(files); + assertThat(service.save("TR-A","1",command).details().evidenceFiles()).containsExactly(file); + verifyNoInteractions(files); + assertThat(service.history("TR-A",1,20).records().get(1).details().evidenceFiles()).containsExactly(file); + assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"attach-proof",82L))).hasMessageContaining("其他实测内容"); + assertThatThrownBy(()->service.save("TR-A","1",evidence(1,"stale-proof",81L))).hasMessageContaining("已变化"); + } + @Test void duplicateOrForeignEvidenceCannotCreateRecordAndEmptyEvidenceIsLegal() { + assertThat(service.save("TR-A","1",evidence(0,"empty-evidence")).recordVersion()).isZero(); + assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"duplicate-evidence",81L,81L))).hasMessageContaining("不同"); + when(files.metadata(71,81)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"文件不属于当前车次")); + assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"foreign-evidence",81L))).hasMessageContaining("不属于"); + assertThat(repository.count("TR-A")).isZero(); + } + @Test void uploadDoesNotCreateMeasuredRecordAndHistoricalDownloadRevalidatesOwnership() { + var blob=new org.springframework.mock.web.MockMultipartFile("file","meter.pdf","application/pdf",new byte[]{1}); + var file=new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",1); + when(files.upload(71,"1",blob)).thenReturn(file); + assertThat(service.upload("TR-A","1",blob)).isEqualTo(file);assertThat(repository.count("TR-A")).isZero(); + when(files.upload(71,"1",blob)).thenThrow(new IllegalStateException("upload interrupted")); + assertThatThrownBy(()->service.upload("TR-A","1",blob)).hasMessageContaining("interrupted");assertThat(repository.count("TR-A")).isZero(); + assertThatThrownBy(()->service.download("TR-A",81)).hasMessageContaining("尚未保存"); + when(files.metadata(71,81)).thenReturn(file);when(files.download(71,81)).thenReturn(new byte[]{1}); + service.save("TR-A","1",evidence(0,"bind-for-read",81L)); + service.save("TR-A","1",evidence(1,"remove-reference")); + assertThat(service.download("TR-A",81).content()).containsExactly(1); + assertThatThrownBy(()->service.download("TR-A",82)).hasMessageContaining("尚未保存"); + assertThatThrownBy(()->service.upload("TR-B","1",blob)).hasMessageContaining("实际已发车"); + } } diff --git a/scripts/README.md b/scripts/README.md index 8588d99d4..d3d41c8af 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,7 +12,7 @@ `fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 -`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=a6fd0ba3`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 +`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=8700fc34238bc33339351c73c15f3af8de8ce517`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 `fee-void-recovery-test-env.mjs` 是费用作废确认响应丢失的 TEST 专项入口,默认 `--plan` 零网络。执行须显式传入 `--execute --environment TEST --run-id <新批次> --customer-id <测试客户ID> --control-customer-id <另一测试客户ID> --warehouse-id <海外仓ID>`,外部 TEST/Jenkins 配置及 `FBA_CORE_JENKINS_AGENT` 提供凭据与取证节点。创建一条新仓储服务应收,经提交审核和原发布确认后,对该来源版本的 VOIDED 事件注入 ACK 丢失;原事件 DEAD 后受保护重试,核对最终作废、同命令重放、另一客户费用与完整库存不变。不写业务SQL,不使用旧费用作废;故障窗口会临时重建同镜像 Lastleg,禁止并行发布或其他共享环境写入。报告在 `reports/fee-void-<新批次>/`,请求前保存意图,失败保留样本,不自动重跑或覆盖;finally 恢复原服务、停止代理并退出会话,恢复未证明则失败。`fee-void-recovery-test-env.test.mjs` 是离线编排与失败清理检查,不能代替真实环境结果。 diff --git a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs index bab7bab53..83df4d699 100644 --- a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs @@ -123,6 +123,15 @@ test('independent runner requires explicit service readiness and only the specif assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('test-acceptance', 'dev-acceptance')}`]), /授权目录/) assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).endsWith('test-acceptance.env')) }) + +test('readiness gate accepts only the full production anchor, not old anchors, shorthand or another commit', () => { + const profile = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] + assert.equal(READY_COMMIT, '8700fc34238bc33339351c73c15f3af8de8ce517') + for (const commit of ['a6fd0ba3', 'cb6c54ff', '8700fc34', 'd05ff1cd', `${READY_COMMIT}0`]) { + assert.throws(() => requireReady([`--ready-commit=${commit}`], profile), /就绪/) + } + assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], profile).endsWith('test-acceptance.env')) +}) test('company cost verifier exercises CNY/USD, void/rebuild, payment replay and customer isolation without direct transport', async () => { const { context, calls, vehicles, records, costs } = fixture() await run(context, options) diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs index 91822a415..126ce07ce 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs @@ -8,7 +8,7 @@ function harness(fault) { const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = [] let next = 0, supplierStatus = 'ACTIVE' - let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null } } + let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] } } const measuredHistory = [] const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] } const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' }) @@ -75,7 +75,9 @@ function harness(fault) { const prior = measuredKeys.get(body.idempotencyKey) if (prior) { if (fault === 'measured replay adds history') measuredHistory.push(copy(measured)); return response(prior) } if (String(body.recordVersion) !== measured.recordVersion) return response(null, 'A0409') - measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: copy(body.details) } + assert.deepEqual(body.details.evidenceFileIds, []); assert.equal(Object.hasOwn(body.details, 'evidenceFiles'), false) + const { evidenceFileIds, ...values } = body.details + measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: { ...copy(values), evidenceFiles: [] } } measuredKeys.set(body.idempotencyKey, copy(measured)); measuredHistory.unshift(copy(measured)); return response(measured) } if (p === '/api/platform/v1/last-leg/fleet/operations') { diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index fca72635d..640cb3ec6 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -285,12 +285,12 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames const recordPath = `${OPERATIONS}/${encode(fixture.tripNo)}/record` const initial = await command('GET', recordPath, undefined, '核对本轮车次尚无实测记录') check('禁止覆盖已有实测历史样本', null, id(initial.recordVersion) === '0' && initial.details.actualDistanceMiles == null && initial.details.actualFuelLitres == null) - const measured = { recordVersion: initial.recordVersion, idempotencyKey: key('measured-zero'), details: { actualDistanceMiles: 0, actualFuelLitres: null, remark: context.marker }, reason: null } + const measured = { recordVersion: initial.recordVersion, idempotencyKey: key('measured-zero'), details: { actualDistanceMiles: 0, actualFuelLitres: null, remark: context.marker, evidenceFileIds: [] }, reason: null } const first = await command('PUT', recordPath, measured, '登记明确零里程和未知油耗') check('实测明确零值不变成未填写', null, first.details.actualDistanceMiles != null && Number(first.details.actualDistanceMiles) === 0 && first.details.actualFuelLitres == null) const firstAgain = await command('PUT', recordPath, measured, '重放实测保存') check('实测重放保留原版本', null, id(firstAgain.recordVersion) === id(first.recordVersion)) - const corrected = await command('PUT', recordPath, { recordVersion: first.recordVersion, idempotencyKey: key('measured-correct'), details: { actualDistanceMiles: 25.5, actualFuelLitres: 8.2, remark: context.marker }, reason: '核对受控实测' }, '更正实测里程油耗') + const corrected = await command('PUT', recordPath, { recordVersion: first.recordVersion, idempotencyKey: key('measured-correct'), details: { actualDistanceMiles: 25.5, actualFuelLitres: 8.2, remark: context.marker, evidenceFileIds: [] }, reason: '核对受控实测' }, '更正实测里程油耗') await reject('PUT', recordPath, { ...measured, idempotencyKey: key('measured-stale') }, 'A0409', '旧实测版本拒绝') const measuredHistory = await command('GET', `${recordPath}/history?page=1&size=20`, undefined, '读取实测更正历史') check('实测重放与冲突没有追加历史', null, Number(measuredHistory.total) === 2 && version(corrected.recordVersion) === version(first.recordVersion) + 1n) diff --git a/scripts/fleet-company-cost-test-env.mjs b/scripts/fleet-company-cost-test-env.mjs index 1c3b72ece..434795a29 100644 --- a/scripts/fleet-company-cost-test-env.mjs +++ b/scripts/fleet-company-cost-test-env.mjs @@ -6,7 +6,10 @@ import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { run as verifyCompanyCost } from './admin-test-env-modules/fleet-company-cost-acceptance.mjs' export const MARKER = 'FLEET-COST-20261003' -export const READY_COMMIT = 'a6fd0ba3' +// Production source anchor, not a deployment-health assertion. The coordinator must independently +// verify the deployed commit (including any descendant) and service readiness before passing this exact anchor. +// This gate intentionally does not infer ancestry, compare deployed source, or perform network checks. +export const READY_COMMIT = '8700fc34238bc33339351c73c15f3af8de8ce517' export function requireReady(args, execArgs) { if (!args.includes(`--ready-commit=${READY_COMMIT}`)) throw new Error('等待总控确认目标服务就绪后,才可显式传入本轮ready-commit') return requireTestProfile(execArgs) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index bb49494a9..4ccc1769c 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,21 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '公司车辆付款凭证按平台及费用ID校验;上传创建不可变对象且无远端幂等键,读取可重复,禁止传输层自动重试上传'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '按维修/救援来源批量只读查询公司费用状态;POST为查询,不触发入账或付款'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '维修/救援登记读取并校验权威供应商状态,不创建或修改供应商'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '采购报价读取启用卡车供应商及服务范围;内部令牌保护,只读供应商主档'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 0a673d213..7ccbb4500 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -102,7 +102,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 66) + assert.equal(reviewed.length, 70) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -118,7 +118,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 24) + assert.equal(reviewed.length, 25) assert.deepEqual(discovered.sort(), reviewed) }) @@ -210,8 +210,11 @@ test('unified authority checks remain infrastructure edges and direct events rem assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) const financeReads = SYNCHRONOUS_CLIENTS.filter(client => client.source === 'lastleg-service' && client.target === 'finance-service' && client.contexts.includes('REQUEST')) - assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]), [[ + assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]) + .sort((left, right) => left[0].localeCompare(right[0])), [[ 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ' + ], [ + 'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', 'READ', 'SAFE_READ' ]]) assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) })