From 0558f7ae5908b362857df8887efbc6072a58218b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 8 Aug 2026 20:36:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalBusinessFileController.java | 3 +++ .../service/InternalBusinessFileService.java | 20 +++++++++++++++++++ .../client/TruckAppointmentFileClient.java | 3 +++ .../PlatformTruckAppointmentController.java | 2 ++ .../TruckAppointmentRepository.java | 2 +- .../service/TruckAppointmentService.java | 10 +++++++++- .../service-center-flow.mjs | 18 ++++++++++++++--- .../truck-appointments.mjs | 18 ++++++++++++++++- 8 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java create mode 100644 file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java new file mode 100644 index 000000000..f06d7d9cd --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -0,0 +1,3 @@ +package com.baiyue.file.controller; +import com.baiyue.common.core.api.*;import com.baiyue.common.core.exception.BizException;import com.baiyue.file.service.InternalBusinessFileService;import jakarta.annotation.Resource;import jakarta.validation.constraints.*;import org.springframework.beans.factory.annotation.Value;import org.springframework.http.MediaType;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile; +@RestController @RequestMapping("/internal/file/v1/business-files") public class InternalBusinessFileController{@Resource InternalBusinessFileService service;@Value("${baiyue.security.internal-service-token}") String expected;private void check(String token){if(token==null||!java.security.MessageDigest.isEqual(token.getBytes(java.nio.charset.StandardCharsets.UTF_8),expected.getBytes(java.nio.charset.StandardCharsets.UTF_8)))throw new BizException(ResultCode.FORBIDDEN,"内部服务令牌无效");}@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse upload(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@RequestPart("file") MultipartFile file){check(token);return ApiResponse.success(service.upload(tenantId,businessType,businessId,operatorId,file));}@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") public ApiResponse metadata(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable @Positive long tenantId,@PathVariable @NotBlank String businessType,@PathVariable @NotBlank String businessId,@PathVariable @Positive long fileId){check(token);return ApiResponse.success(service.metadata(tenantId,businessType,businessId,fileId));}} diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java new file mode 100644 index 000000000..f061b867a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -0,0 +1,20 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; + +@Service +public class InternalBusinessFileService { + private static final String TRUCK_ATTACHMENT = "TRUCK_APPOINTMENT_ATTACHMENT"; + @Resource FileObjectMapper files; @Resource MeasurementImageStorage storage; + public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!TRUCK_ATTACHMENT.equals(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long appointmentId;try{appointmentId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"预约ID格式无效");}if(appointmentId<=0)throw new BizException(ResultCode.BAD_REQUEST,"预约ID必须有效");var stored=storage.storeLoading(tenantId,appointmentId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(TRUCK_ATTACHMENT);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("loading-evidence");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return new Metadata(value.getId(),tenantId,TRUCK_ATTACHMENT,businessId,value.getOriginalName(),value.getSha256());} + public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){FileObjectEntity value=files.selectOne(new LambdaQueryWrapper().eq(FileObjectEntity::getTenantId,tenantId).eq(FileObjectEntity::getBusinessType,businessType).eq(FileObjectEntity::getBusinessId,businessId).eq(FileObjectEntity::getId,fileId).eq(FileObjectEntity::getStatus,"ACTIVE").eq(FileObjectEntity::getDeleted,false));if(value==null)throw new BizException(ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配");return new Metadata(value.getId(),value.getTenantId(),value.getBusinessType(),value.getBusinessId(),value.getOriginalName(),value.getSha256());} + public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256){} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java new file mode 100644 index 000000000..636fa0c09 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java @@ -0,0 +1,3 @@ +package com.baiyue.firstleg.client; +import com.baiyue.common.core.api.ApiResponse;import org.springframework.cloud.openfeign.FeignClient;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile; +@FeignClient(name="file-service",contextId="truckAppointmentFileClient",url="${baiyue.clients.file.url:}",path="/internal/file/v1/business-files") public interface TruckAppointmentFileClient{@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes="multipart/form-data") ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@RequestPart("file") MultipartFile file);@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256){} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java index b88588301..4557fa17f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java @@ -6,6 +6,8 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api. @GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));} @GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));} @PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));} + @PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam(required=false) String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));} + @DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);} @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:create')") @BusinessAudit(event="TRUCK_APPOINTMENT_CREATE",resourceType="TRUCK_APPOINTMENT",captureBefore=false) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(u.userId(),u.username(),r));} @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse edit(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.edit(id,u.userId(),u.username(),r));} @PostMapping("/{id}/{action}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:operate')") @BusinessAudit(event="TRUCK_APPOINTMENT_ACTION",resourceType="TRUCK_APPOINTMENT") public ApiResponse action(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@PathVariable String action,@RequestHeader(value="Idempotency-Key",required=false) String idem,@RequestHeader(value="X-Request-Hash",required=false) String hash,@Valid @RequestBody ActionRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.action(id,action.toUpperCase(),r,idem,hash,u.userId(),u.username()));} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index a23434390..1d96e1ca1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -2,5 +2,5 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations; public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} - public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} + public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index b44c0bd45..9a3333a79 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -10,19 +10,27 @@ import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.repository.TruckAppointmentRepository; +import com.baiyue.firstleg.client.TruckAppointmentFileClient; import jakarta.annotation.Resource; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.Objects; +import org.springframework.web.multipart.MultipartFile; @Service public class TruckAppointmentService { @Resource TruckAppointmentRepository repo; @Resource OceanBookingRepository oceanBookings; + @Resource TruckAppointmentFileClient files; + @Value("${baiyue.security.internal-service-token}") String internalToken; public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List attachments(long id){required(id);return repo.attachments(id);} - @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){required(id); TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(r.fileId());a.setAttachmentType(r.attachmentType());a.setFileName(r.fileName());a.setFileHash(r.fileHash());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} + @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(type);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} + @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(r.attachmentType());a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} + @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); validateBookingLink(r); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); validateBookingLink(r); copy(e,r); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String hash,long uid,String name){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); e.setStatus(to);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1); record(e,from.name(),to.name(),action,r.reason(),uid,name); operation(e,action,idem,hash); return e;} diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index b7dc55e6f..538d42774 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -40,7 +40,19 @@ export async function run(context) { const cancelled = context.apiSuccess(MODULE, '取消跨服务链路拖车预约', action, '', OPTIONS) context.assert(MODULE, '跨服务链路安全终态为 CANCELLED', action, cancelled?.status === 'CANCELLED', `status=${cancelled?.status || 'missing'}`, OPTIONS) const detail = await context.request('GET', `${TRUCK}/${appointmentId}`) - const final = context.apiSuccess(MODULE, '回查跨服务链路审计历史', detail, '', OPTIONS) - context.addCoverageGap(MODULE, 'service-center-cross-flow', '拖车预约状态历史独立查询', - '当前拖车预约 Controller 仅提供详情实体,没有公开 histories 查询字段/端点;已验证详情中的关联 ID、版本和审计时间字段', 'no-public-history-api') + const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) + context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, + final?.status === 'CANCELLED' && id(final?.oceanBookingId) === id(ocean.id), + `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS) + + const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) + const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS) + const cancel = Array.isArray(history) ? history.find((item) => item?.action === 'CANCEL') : null + context.assert(MODULE, '跨服务链路历史包含归属、状态、操作者、时间和备注', historyResponse, + Boolean(cancel) && id(cancel?.appointmentId) === appointmentId + && cancel?.fromStatus === 'DRAFT' && cancel?.toStatus === 'CANCELLED' + && Boolean(cancel?.operatorId) && Boolean(cancel?.operatorName) && Boolean(cancel?.createdAt) + && cancel?.reason === context.marker, + `history=${history?.length ?? 'n/a'}, cancelFrom=${cancel?.fromStatus || 'missing'}, cancelTo=${cancel?.toStatus || 'missing'}`, + OPTIONS) } diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 220840ffd..7fabc8a10 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -9,4 +9,20 @@ async function req(context, method, path, body, headers = {}) { try { const resp function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } function payload(context, suffix, version = 0) { return { outboundOrderId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, warehouseId: 193000000000000001, customerId: 10001, customerName: `拖车验收客户-${suffix}`, carrierId: null, carrierName: `承运商-${suffix}`, fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-09-02T08:00:00', transitMinutes: 120, payableAmount: 100, receivableAmount: 150, currency: 'RMB', remark: context.marker, expectedVersion: version } } export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10); const list = await req(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`); if (missing(list) || list.http === 0) { context.addStep(MODULE, '拖车预约服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`, OPTIONS); return } const page = context.apiSuccess(MODULE, '拖车预约分页 keyword 查询', list, '', OPTIONS); context.assert(MODULE, '拖车预约分页结构正确', list, Array.isArray(page?.records), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS); for (const status of ['DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED']) { const r = await req(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); context.addStep(MODULE, `拖车预约 ${status} 筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`, OPTIONS) } - const createResponse = await req(context, 'POST', BASE, payload(context, suffix)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS); const appointmentId = id(appointment?.id); context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS); if (!appointmentId) return; context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'CANCELLED' }; const path = `${BASE}/${appointmentId}`; let version = Number(appointment.version); const edit = await req(context, 'PUT', path, { ...payload(context, suffix, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version); const stale = await req(context, 'PUT', path, { ...payload(context, suffix, 0), vehicleNo: `STALE-${suffix}` }); context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS); const after = await req(context, 'GET', path); const ad = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS); context.assert(MODULE, '拖车预约冲突未部分更新', after, ad?.vehicleNo === edited?.vehicleNo, `vehicleNo=${ad?.vehicleNo || 'missing'}`, OPTIONS); version = Number(ad?.version); const action = async (name, endpoint, expected) => { const r = await req(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = context.apiSuccess(MODULE, name, r, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, r, Boolean(d?.status), `status=${d?.status || 'missing'}`, OPTIONS); version = Number(d?.version); return d }; for (const [name, endpoint] of [['提交预约','submit'],['派车','dispatch'],['开始提货','pickup'],['到仓','arrive'],['开始装货','load'],['离场','depart'],['完成','complete']]) await action(name, endpoint, version); const final = await req(context, 'GET', path); const fd = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS); context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, fd?.status === 'COMPLETED' && fd?.payableAmount != null && fd?.receivableAmount != null, `status=${fd?.status || 'missing'}`, OPTIONS); context.addCoverageGap(MODULE, 'truck-appointment-lifecycle', '附件引用安全边界', '当前 Controller 无附件上传/引用 API,未伪造附件成功', 'no-public-fixture-api') } + const createResponse = await req(context, 'POST', BASE, payload(context, suffix)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS); const appointmentId = id(appointment?.id); context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS); if (!appointmentId) return; context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'CANCELLED' }; const path = `${BASE}/${appointmentId}`; let version = Number(appointment.version); const edit = await req(context, 'PUT', path, { ...payload(context, suffix, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version); const stale = await req(context, 'PUT', path, { ...payload(context, suffix, 0), vehicleNo: `STALE-${suffix}` }); context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS); const after = await req(context, 'GET', path); const ad = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS); context.assert(MODULE, '拖车预约冲突未部分更新', after, ad?.vehicleNo === edited?.vehicleNo, `vehicleNo=${ad?.vehicleNo || 'missing'}`, OPTIONS); version = Number(ad?.version); const action = async (name, endpoint, expected) => { const r = await req(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = context.apiSuccess(MODULE, name, r, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, r, Boolean(d?.status), `status=${d?.status || 'missing'}`, OPTIONS); version = Number(d?.version); return d }; for (const [name, endpoint] of [['提交预约','submit'],['派车','dispatch'],['开始提货','pickup'],['到仓','arrive'],['开始装货','load'],['离场','depart'],['完成','complete']]) await action(name, endpoint, version); const final = await req(context, 'GET', path); const fd = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS); context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, fd?.status === 'COMPLETED' && fd?.payableAmount != null && fd?.receivableAmount != null, `status=${fd?.status || 'missing'}`, OPTIONS) + + const historyResponse = await req(context, 'GET', `${path}/history`) + const history = context.apiSuccess(MODULE, '查询拖车预约状态历史', historyResponse, '', OPTIONS) + const expectedActions = ['CREATE', 'EDIT', 'SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD', 'DEPART', 'COMPLETE'] + const actualActions = Array.isArray(history) ? history.map((item) => item?.action) : [] + context.assert(MODULE, '拖车预约历史动作和状态链完整', historyResponse, + Array.isArray(history) && expectedActions.every((value) => actualActions.includes(value)) + && history.every((item) => id(item?.appointmentId) === appointmentId + && Boolean(item?.action) && Object.hasOwn(item, 'fromStatus') && Boolean(item?.toStatus) + && Boolean(item?.operatorId) && Boolean(item?.operatorName) && Boolean(item?.createdAt)), + `history=${history?.length ?? 'n/a'}, actions=${actualActions.join(',')}`, OPTIONS) + + const missingHistory = await req(context, 'GET', `${BASE}/999999999999999999/history`) + context.addStep(MODULE, '不存在预约不能读取状态历史', missingHistory, + failure(missingHistory), `HTTP=${missingHistory.http}, code=${codeOf(missingHistory)}`, OPTIONS) +}