From 06db4e3432c70a3b27c33505c99b6cb3764692d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 09:11:56 +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 --- deploy/sql/头程数据库统一增量-MySQL8.sql | 34 ++++++- .../PlatformTruckAppointmentController.java | 4 +- .../entity/TruckAppointmentEntity.java | 2 + .../mapper/TruckAppointmentMapper.java | 2 +- .../TruckAppointmentOperationMapper.java | 28 +++++- .../TruckAppointmentRepository.java | 1 + .../service/TruckAppointmentService.java | 19 +++- .../db/phase3-truck-appointment-schema.sql | 34 ++++++- .../openapi/truck-appointments-v1.yaml | 92 ++++++++++++++++++- ...ruckAppointmentControllerContractTest.java | 11 ++- .../service/TruckAppointmentServiceTest.java | 64 ++++++++++--- 11 files changed, 265 insertions(+), 26 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a4b5642bd..108460225 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7828,7 +7828,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( store_name VARCHAR(128) NULL, store_contact VARCHAR(64) NULL, store_phone VARCHAR(32) NULL, store_address VARCHAR(255) NULL, store_city VARCHAR(64) NULL, store_zipcode VARCHAR(16) NULL, package_count INT UNSIGNED NULL, gross_weight_kg DECIMAL(18,3) NULL, volume_cbm DECIMAL(18,4) NULL, pallet_count INT UNSIGNED NULL, po_no VARCHAR(255) NULL, planned_loading_at DATETIME(3) NULL, - actual_loading_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL, + actual_arrived_warehouse_at DATETIME(3) NULL, actual_loading_at DATETIME(3) NULL, actual_departed_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL, currency VARCHAR(8) NOT NULL DEFAULT 'RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', revision INT UNSIGNED NOT NULL DEFAULT 0, version INT UNSIGNED NOT NULL DEFAULT 0, remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), @@ -7850,6 +7850,38 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( action VARCHAR(32) NOT NULL, response_snapshot JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id) ) ENGINE=InnoDB COMMENT='拖车操作幂等记录'; +DROP PROCEDURE IF EXISTS phase3_truck_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_truck_add_column( + IN p_column_name VARCHAR(64), IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'fl_truck_appointment' + AND column_name = p_column_name + ) THEN + SET @phase3_truck_ddl = CONCAT( + 'ALTER TABLE fl_truck_appointment ADD COLUMN `', p_column_name, '` ', p_definition + ); + PREPARE phase3_truck_stmt FROM @phase3_truck_ddl; + EXECUTE phase3_truck_stmt; + DEALLOCATE PREPARE phase3_truck_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_truck_add_column( + 'actual_arrived_warehouse_at', + 'DATETIME(3) NULL AFTER planned_loading_at' +); +CALL phase3_truck_add_column( + 'actual_departed_at', + 'DATETIME(3) NULL AFTER actual_loading_at' +); +DROP PROCEDURE IF EXISTS phase3_truck_add_column; + USE baiyue_iam; DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; DELIMITER $$ 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 4557fa17f..8ac12bbf4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java @@ -10,6 +10,6 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api. @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()));} - @PostMapping("/{id}/reschedule") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:operate')") public ApiResponse reschedule(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@RequestParam LocalDateTime plannedLoadingAt,@Valid @RequestBody ActionRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.reschedule(id,plannedLoadingAt,r,u.userId(),u.username()));} + @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("Idempotency-Key") 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()));} + @PostMapping("/{id}/reschedule") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:operate')") public ApiResponse reschedule(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@RequestHeader("Idempotency-Key") String idem,@RequestHeader(value="X-Request-Hash",required=false) String hash,@RequestParam LocalDateTime plannedLoadingAt,@Valid @RequestBody ActionRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.reschedule(id,plannedLoadingAt,r,idem,hash,u.userId(),u.username()));} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index c00b081c0..c8afd1f65 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -40,7 +40,9 @@ public class TruckAppointmentEntity { private Integer palletCount; private String poNo; private LocalDateTime plannedLoadingAt; + private LocalDateTime actualArrivedWarehouseAt; private LocalDateTime actualLoadingAt; + private LocalDateTime actualDepartedAt; private Integer transitMinutes; private BigDecimal payableAmount; private BigDecimal receivableAmount; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 275c3f83b..538c8ebd7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ @Select("") Page page(Page p,@Param("q") Query q); - @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentOperationMapper.java index 3c5365353..ad4d6cf23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentOperationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentOperationMapper.java @@ -1 +1,27 @@ -package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface TruckAppointmentOperationMapper extends BaseMapper{} +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface TruckAppointmentOperationMapper extends BaseMapper { + @Insert("INSERT IGNORE INTO fl_truck_appointment_operation " + + "(id,appointment_id,idempotency_key,request_hash,action,created_at) " + + "VALUES(#{id},#{appointmentId},#{key},#{hash},#{action},CURRENT_TIMESTAMP(3))") + int claim(@Param("id") long id, @Param("appointmentId") long appointmentId, + @Param("action") String action, @Param("key") String key, @Param("hash") String hash); + + @Select("SELECT * FROM fl_truck_appointment_operation WHERE idempotency_key=#{key} LIMIT 1") + TruckAppointmentOperationEntity find(@Param("key") String key); + + @Update("UPDATE fl_truck_appointment_operation SET response_snapshot=#{response} " + + "WHERE appointment_id=#{appointmentId} AND action=#{action} AND idempotency_key=#{key} " + + "AND response_snapshot IS NULL") + int complete(@Param("appointmentId") long appointmentId, @Param("action") String action, + @Param("key") String key, @Param("response") String response); +} 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 1d96e1ca1..2bb033a54 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,6 @@ 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 boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} 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 961fa692b..d082f19d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -17,7 +17,13 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.LocalDateTime; +import java.util.HexFormat; import java.util.Objects; import org.springframework.web.multipart.MultipartFile; @@ -27,6 +33,7 @@ public class TruckAppointmentService { @Resource OceanBookingRepository oceanBookings; @Resource SmartLoadingRepository loadingPlans; @Resource TruckAppointmentFileClient files; + @Resource ObjectMapper objectMapper; @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);} @@ -35,12 +42,18 @@ public class TruckAppointmentService { @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;} - @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,long uid,String name){TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");e.setStatus(TruckAppointmentStatus.RESCHEDULED);e.setPlannedLoadingAt(at);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1);record(e,from,"RESCHEDULED","RESCHEDULE",r.reason(),uid,name);return e;} + @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;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("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} + @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} private void validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=oceanBookings.containerBooking(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);} private void validateLoadingPlanLink(SaveRequest r){if(r.loadingPlanId()==null)return;if(r.loadingPlanId()<=0)throw bad("loadingPlanId必须大于0");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("关联装柜计划时containerBookingId不能为空");LoadingPlanEntity plan=loadingPlans.plan(r.loadingPlanId());if(plan==null)throw new BizException(ResultCode.NOT_FOUND,"关联装柜计划不存在");if(!Objects.equals(plan.getContainerBookingId(),r.containerBookingId()))throw bad("loadingPlanId与柜体不一致");} private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} private void copy(TruckAppointmentEntity e,SaveRequest r){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(r.customerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} - private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} private void operation(TruckAppointmentEntity e,String action,String key,String hash){if(key==null||key.isBlank())return;TruckAppointmentOperationEntity o=new TruckAppointmentOperationEntity();o.setId(IdWorker.getId());o.setAppointmentId(e.getId());o.setAction(action);o.setIdempotencyKey(key);o.setRequestHash(hash==null?"":hash);o.setCreatedAt(LocalDateTime.now());repo.operation(o);} + private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} + private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} + private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} + private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");} + private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");} + private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){} + private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} } diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index b06ef6593..85dd1686c 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( store_name VARCHAR(128) NULL, store_contact VARCHAR(64) NULL, store_phone VARCHAR(32) NULL, store_address VARCHAR(255) NULL, store_city VARCHAR(64) NULL, store_zipcode VARCHAR(16) NULL, package_count INT UNSIGNED NULL, gross_weight_kg DECIMAL(18,3) NULL, volume_cbm DECIMAL(18,4) NULL, pallet_count INT UNSIGNED NULL, po_no VARCHAR(255) NULL, planned_loading_at DATETIME(3) NULL, - actual_loading_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL, + actual_arrived_warehouse_at DATETIME(3) NULL, actual_loading_at DATETIME(3) NULL, actual_departed_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL, currency VARCHAR(8) NOT NULL DEFAULT 'RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', revision INT UNSIGNED NOT NULL DEFAULT 0, version INT UNSIGNED NOT NULL DEFAULT 0, remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), @@ -30,6 +30,38 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( action VARCHAR(32) NOT NULL, response_snapshot JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id) ) ENGINE=InnoDB COMMENT='拖车操作幂等记录'; +DROP PROCEDURE IF EXISTS phase3_truck_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_truck_add_column( + IN p_column_name VARCHAR(64), IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'fl_truck_appointment' + AND column_name = p_column_name + ) THEN + SET @phase3_truck_ddl = CONCAT( + 'ALTER TABLE fl_truck_appointment ADD COLUMN `', p_column_name, '` ', p_definition + ); + PREPARE phase3_truck_stmt FROM @phase3_truck_ddl; + EXECUTE phase3_truck_stmt; + DEALLOCATE PREPARE phase3_truck_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_truck_add_column( + 'actual_arrived_warehouse_at', + 'DATETIME(3) NULL AFTER planned_loading_at' +); +CALL phase3_truck_add_column( + 'actual_departed_at', + 'DATETIME(3) NULL AFTER actual_loading_at' +); +DROP PROCEDURE IF EXISTS phase3_truck_add_column; + USE baiyue_iam; DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; DELIMITER $$ diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index e005a4257..04c6ec185 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -1,8 +1,71 @@ openapi: 3.0.3 info: - title: Truck Appointment Attachments and History API - version: 1.0.0 + title: Truck Appointment API + version: 1.1.0 paths: + /api/platform/v1/truck-appointments: + get: + summary: Query truck appointments + responses: + '200': {description: Paged truck appointments} + post: + summary: Create a truck appointment linked to a released ocean booking + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SaveRequest'} + responses: + '200': {description: Created appointment} + /api/platform/v1/truck-appointments/{id}: + parameters: [{$ref: '#/components/parameters/AppointmentId'}] + get: + summary: Query a truck appointment + responses: + '200': {description: Appointment detail} + put: + summary: Update an editable truck appointment with optimistic locking + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SaveRequest'} + responses: + '200': {description: Updated appointment} + /api/platform/v1/truck-appointments/{id}/{action}: + parameters: + - {$ref: '#/components/parameters/AppointmentId'} + - {$ref: '#/components/parameters/Action'} + - {$ref: '#/components/parameters/IdempotencyKey'} + - {$ref: '#/components/parameters/RequestHash'} + post: + summary: Apply an idempotent appointment lifecycle action + description: ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ActionRequest'} + responses: + '200': {description: Updated or replayed appointment} + /api/platform/v1/truck-appointments/{id}/reschedule: + parameters: + - {$ref: '#/components/parameters/AppointmentId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + - {$ref: '#/components/parameters/RequestHash'} + - name: plannedLoadingAt + in: query + required: true + schema: {type: string, format: date-time} + post: + summary: Idempotently reschedule an appointment + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ActionRequest'} + responses: + '200': {description: Rescheduled or replayed appointment} /api/platform/v1/truck-appointments/{id}/history: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: @@ -47,3 +110,28 @@ components: parameters: AppointmentId: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} AttachmentId: {name: attachmentId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + Action: + name: action + in: path + required: true + schema: + type: string + enum: [SUBMIT, DISPATCH, PICKUP, ARRIVE, LOAD, DEPART, COMPLETE, EXCEPTION, CANCEL] + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}} + schemas: + ActionRequest: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} + reason: {type: string, maxLength: 500} + SaveRequest: + type: object + required: [oceanBookingId, plannedLoadingAt, expectedVersion] + properties: + oceanBookingId: {type: integer, format: int64, minimum: 1} + containerBookingId: {type: integer, format: int64, minimum: 1} + loadingPlanId: {type: integer, format: int64, minimum: 1} + plannedLoadingAt: {type: string, format: date-time} + expectedVersion: {type: integer, minimum: 0} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index ad2540cb5..11c9762c7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -17,7 +17,8 @@ import static org.assertj.core.api.Assertions.assertThat; class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} - @Test void schemaHasAggregateTablesAndStates() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED");} + @Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");} + @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:view"), @@ -28,10 +29,12 @@ class TruckAppointmentControllerContractTest { Map.entry("reschedule","platform:firstleg:truck-appointment:operate")); for (Method m: PlatformTruckAppointmentController.class.getDeclaredMethods()) { if (!expected.containsKey(m.getName())) continue; assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+expected.get(m.getName())+"')"); assertThat(java.util.Arrays.stream(m.getParameterAnnotations()).anyMatch(a -> java.util.Arrays.stream(a).anyMatch(x -> x.annotationType()==AuthenticationPrincipal.class))).isTrue(); } } - @Test void actionKeepsOptionalIdempotencyHeaders() throws Exception { + @Test void actionAndRescheduleRequireIdempotencyKey() throws Exception { Method action=PlatformTruckAppointmentController.class.getDeclaredMethod("action", AuthenticatedUser.class,long.class,String.class,String.class,String.class,ActionRequest.class); RequestHeader[] headers=java.util.Arrays.stream(action.getParameterAnnotations()).flatMap(java.util.Arrays::stream).filter(a -> a instanceof RequestHeader).map(a -> (RequestHeader)a).toArray(RequestHeader[]::new); - assertThat(java.util.Arrays.stream(headers).map(RequestHeader::value)).contains("Idempotency-Key","X-Request-Hash"); assertThat(java.util.Arrays.stream(headers).allMatch(h -> !h.required())).isTrue(); - assertThat(PlatformTruckAppointmentController.class.getDeclaredMethod("reschedule", AuthenticatedUser.class,long.class,LocalDateTime.class,ActionRequest.class)).isNotNull(); + assertThat(java.util.Arrays.stream(headers).map(RequestHeader::value)).contains("Idempotency-Key","X-Request-Hash"); assertThat(java.util.Arrays.stream(headers).filter(h -> h.value().equals("Idempotency-Key")).allMatch(RequestHeader::required)).isTrue(); + Method reschedule=PlatformTruckAppointmentController.class.getDeclaredMethod("reschedule", AuthenticatedUser.class,long.class,String.class,String.class,LocalDateTime.class,ActionRequest.class); + RequestHeader[] rescheduleHeaders=java.util.Arrays.stream(reschedule.getParameterAnnotations()).flatMap(java.util.Arrays::stream).filter(a -> a instanceof RequestHeader).map(a -> (RequestHeader)a).toArray(RequestHeader[]::new); + assertThat(java.util.Arrays.stream(rescheduleHeaders).map(RequestHeader::value)).contains("Idempotency-Key","X-Request-Hash"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 1156a7d43..f750c5f6a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -20,6 +20,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import java.lang.reflect.Constructor; import java.time.LocalDateTime; @@ -33,6 +36,7 @@ class TruckAppointmentServiceTest { @Mock TruckAppointmentRepository repository; @Mock OceanBookingRepository oceanBookings; @Mock SmartLoadingRepository loadingPlans; + @Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); @InjectMocks TruckAppointmentService service; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -108,38 +112,70 @@ class TruckAppointmentServiceTest { } @Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() { - TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0); - when(repository.find(1L)).thenReturn(e); + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(true); when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1); - var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", "hash-1", 7L, "admin"); + var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin"); assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); assertThat(result.getVersion()).isEqualTo(1); assertThat(result.getRevision()).isEqualTo(1); - ArgumentCaptor operation = ArgumentCaptor.forClass(TruckAppointmentOperationEntity.class); - verify(repository).operation(operation.capture()); - assertThat(operation.getValue().getIdempotencyKey()).isEqualTo("idem-1"); + verify(repository).claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), anyString()); + verify(repository).completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString()); verify(repository).history(any(TruckAppointmentHistoryEntity.class)); } + @Test void actionReplaysCompletedRequestWithoutApplyingTransitionAgain() throws Exception { + TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); + TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity(); + operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1"); + String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交")); + operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed)); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false); + when(repository.operation("idem-1")).thenReturn(operation); + + var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin"); + + assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void actionRejectsMissingKeyAndMismatchedClientHash() { + var request = new ActionRequest(0, "提交"); + assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key"); + assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("X-Request-Hash"); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + } + @Test void rejectsRescheduleThroughGenericActionAndCompletedReschedule() { TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0); when(repository.find(1L)).thenReturn(e); - assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), null, null, 7L, "admin")) + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true); + assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("专用接口"); e.setStatus(TruckAppointmentStatus.COMPLETED); - assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), 7L, "admin")) + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true); + assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("不可改约"); verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt()); } @Test void rescheduleKeepsFromStatusAndIncrementsOptimisticFields() { - TruckAppointmentEntity e = entity(TruckAppointmentStatus.DISPATCHED, 2); - when(repository.find(1L)).thenReturn(e); + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.RESCHEDULED, 3); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true); when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1); var at = LocalDateTime.of(2026, 8, 12, 10, 0); - var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), 7L, "admin"); + var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin"); assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED); assertThat(result.getVersion()).isEqualTo(3); ArgumentCaptor history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); @@ -148,6 +184,12 @@ class TruckAppointmentServiceTest { assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED"); } + private String requestHash(String action, long id, Object request) throws Exception { + String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request); + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) { TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e; }