优化
This commit is contained in:
@@ -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 $$
|
||||
|
||||
+2
-2
@@ -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<Void> 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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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()));}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TruckAppointmentEntity>{
|
||||
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> 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);
|
||||
}
|
||||
|
||||
+27
-1
@@ -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<TruckAppointmentOperationEntity>{}
|
||||
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<TruckAppointmentOperationEntity> {
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -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<TruckAppointmentEntity> 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<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().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<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
|
||||
}
|
||||
|
||||
@@ -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<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);}
|
||||
public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> 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);}
|
||||
}
|
||||
|
||||
@@ -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 $$
|
||||
|
||||
@@ -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}
|
||||
|
||||
+7
-4
@@ -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<String,String> 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");
|
||||
}
|
||||
}
|
||||
|
||||
+53
-11
@@ -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<TruckAppointmentOperationEntity> 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<TruckAppointmentHistoryEntity> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user