This commit is contained in:
2026-08-25 14:46:06 +08:00
parent 5abc7543ae
commit f325d57fc1
106 changed files with 3682 additions and 450 deletions
@@ -10,12 +10,19 @@ import java.net.http.HttpClient;
public class LastlegRestClientFactory {
public RestClient build(RestClient.Builder builder, String baseUrl,
LastlegRestClientProperties.ClientTimeouts timeouts) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(timeouts.getReadTimeout());
JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts);
return builder.requestFactory(requestFactory).baseUrl(baseUrl).build();
}
}
HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) {
return HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
}
JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) {
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts));
requestFactory.setReadTimeout(timeouts.getReadTimeout());
return requestFactory;
}
}
@@ -50,8 +50,14 @@ public class LastlegOrderController {
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
tokenGuard.verify(token);
int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())
? shippingNodeConsumer.consume(event) : consumer.consume(event);
int consumed;
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
consumed = shippingNodeConsumer.consume(event);
} else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) {
consumed = consumer.consume(event);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType());
}
return ApiResponse.success(consumed);
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
private final VehicleApplicationService service;
public PlatformVehicleController(VehicleApplicationService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleModels.Response>> page(@Valid VehicleModels.Query query) {
return execute(() -> ApiResponse.success(service.page(query)));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleModels.Response> detail(@PathVariable long id) {
return execute(() -> ApiResponse.success(service.detail(id)));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleModels.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleModels.Create request) {
return execute(() -> ApiResponse.success(service.create(operator(user), request)));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleModels.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.Update request) {
return execute(() -> ApiResponse.success(service.update(id, operator(user), request)));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleModels.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.ChangeStatus request) {
return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request)));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleModels.Query query) {
return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private <T> T execute(Supplier<T> action) {
try {
return action.get();
} catch (VehicleFailure failure) {
HttpStatus status = switch (failure.kind()) {
case BAD_REQUEST -> HttpStatus.BAD_REQUEST;
case NOT_FOUND -> HttpStatus.NOT_FOUND;
case CONFLICT -> HttpStatus.CONFLICT;
};
throw new ResponseStatusException(status, failure.getMessage(), failure);
}
}
}
@@ -1,13 +1,12 @@
package com.baiyue.lastleg.masterdata.vehicle.mapper;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface VehicleMapper extends BaseMapper<Vehicle> {
public interface VehicleMapper extends BaseMapper<VehicleRecord> {
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
Vehicle lockById(@Param("id") long id);
VehicleRecord lockById(@Param("id") long id);
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
@Repository
public class VehiclePersistenceAdapter implements VehicleRepository {
private final VehicleMapper vehicles;
public VehiclePersistenceAdapter(VehicleMapper vehicles) {
this.vehicles = vehicles;
}
@Override
public PageSlice page(SearchCriteria criteria) {
Page<VehicleRecord> page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria));
return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
@Override
public List<VehicleSnapshot> list(SearchCriteria criteria) {
return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList();
}
@Override
public Optional<VehicleSnapshot> findById(long id) {
return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot);
}
@Override
public Optional<VehicleSnapshot> lockById(long id) {
return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot);
}
@Override
public long create(NewVehicle vehicle) {
VehicleRecord record = new VehicleRecord();
record.setId(IdWorker.getId());
record.setPlateNumber(vehicle.plateNumber());
record.setModel(vehicle.model());
record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km());
record.setMileageKm(vehicle.mileageKm());
record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany());
record.setPhone(vehicle.phone());
record.setRegion(vehicle.region());
record.setOwnership(vehicle.ownership());
record.setStatus(vehicle.status());
record.setRemark(vehicle.remark());
record.setRecordVersion(0L);
record.setCreatedBy(vehicle.operator());
record.setUpdatedBy(vehicle.operator());
record.setCreatedAt(vehicle.createdAt());
record.setUpdatedAt(vehicle.createdAt());
try {
vehicles.insert(record);
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
return record.getId();
}
@Override
public boolean update(long id, long expectedVersion, VehicleChanges changes) {
try {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getPlateNumber, changes.plateNumber())
.set(VehicleRecord::getModel, changes.model())
.set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km())
.set(VehicleRecord::getMileageKm, changes.mileageKm())
.set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany())
.set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region())
.set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark())
.set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt())
.setSql("record_version = record_version + 1")) == 1;
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
}
@Override
public boolean updateStatus(long id, long expectedVersion, String status, String operator,
java.time.LocalDateTime updatedAt) {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator)
.set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1;
}
private LambdaQueryWrapper<VehicleRecord> wrapper(SearchCriteria criteria) {
return new LambdaQueryWrapper<VehicleRecord>()
.and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword())
.or().like(VehicleRecord::getModel, criteria.keyword())
.or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword()))
.eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership())
.eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region())
.eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status())
.orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId);
}
private VehicleSnapshot snapshot(VehicleRecord value) {
return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private static long version(Long value) {
return value == null ? 0L : value;
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.entity;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
@Data
@TableName("ll_vehicle")
public class Vehicle {
public class VehicleRecord {
@TableId(type = IdType.INPUT) private Long id;
private String plateNumber;
private String model;
@@ -0,0 +1,165 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleApplicationService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
private final VehicleRepository vehicles;
public VehicleApplicationService(VehicleRepository vehicles) {
this.vehicles = vehicles;
}
public PageResult<VehicleModels.Response> page(VehicleModels.Query query) {
var page = vehicles.page(criteria(query));
return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(),
page.size(), page.total(), page.pages());
}
public VehicleModels.Response detail(long id) {
return response(required(id));
}
@Transactional
public VehicleModels.Response create(String operator, VehicleModels.Create request) {
LocalDateTime now = LocalDateTime.now();
long id;
try {
id = vehicles.create(new NewVehicle(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
return detail(id);
}
@Transactional
public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码");
boolean updated;
try {
updated = vehicles.update(id, request.recordVersion(), new VehicleChanges(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now()));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.status())) return response(current);
boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now());
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleModels.Query query) {
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private SearchCriteria criteria(VehicleModels.Query query) {
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
optional(query.region(), REGIONS, "车辆区域"),
optional(query.status(), STATUSES, "车辆状态"));
}
private VehicleSnapshot required(long id) {
return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleSnapshot locked(long id) {
return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleModels.Response response(VehicleSnapshot value) {
return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(),
value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(),
value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(),
value.updatedBy(), value.updatedAt());
}
private void requireVersion(VehicleSnapshot value, long expected) {
if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法");
return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
return value.trim();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); }
private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); }
private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); }
public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT }
public static final class VehicleFailure extends RuntimeException {
private final FailureKind kind;
public VehicleFailure(FailureKind kind, String message) {
super(message);
this.kind = kind;
}
public FailureKind kind() {
return kind;
}
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.dto;
package com.baiyue.lastleg.masterdata.vehicle.application.model;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
@@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class VehicleDtos {
private VehicleDtos() { }
public final class VehicleModels {
private VehicleModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, String ownership, String region, String status) {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface VehicleRepository {
PageSlice page(SearchCriteria criteria);
List<VehicleSnapshot> list(SearchCriteria criteria);
Optional<VehicleSnapshot> findById(long id);
Optional<VehicleSnapshot> lockById(long id);
long create(NewVehicle vehicle);
boolean update(long id, long expectedVersion, VehicleChanges changes);
boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt);
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { }
record PageSlice(List<VehicleSnapshot> records, long page, long size, long total, long pages) { }
record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String status,
String remark, String operator, LocalDateTime createdAt) { }
record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String remark,
String operator, LocalDateTime updatedAt) { }
record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
final class DuplicatePlateException extends RuntimeException {
public DuplicatePlateException(Throwable cause) {
super(cause);
}
}
}
@@ -1,58 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
@Resource private VehicleService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -1,143 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
@Resource private VehicleMapper vehicles;
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public VehicleDtos.Response detail(long id) { return response(required(id)); }
@Transactional
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
LocalDateTime now = LocalDateTime.now();
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
return detail(value.getId());
}
@Transactional
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
int updated;
try {
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
.set(Vehicle::getMileageKm, request.mileageKm())
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleDtos.Query query) {
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
return new LambdaQueryWrapper<Vehicle>()
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
}
private Vehicle required(long id) {
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
}
private Vehicle locked(long id) {
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
}
private VehicleDtos.Response response(Vehicle value) {
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private void requireVersion(Vehicle value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -32,6 +32,8 @@ public class UnloadCompletedOutboxRepository {
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
.set(LastlegEventOutbox::getStatus, "PROCESSING")
@@ -54,6 +54,8 @@ public class LoadingCompletedConsumer {
throw new IllegalArgumentException("尾程交接事件区域无效");
if (!"LOADING_PLAN".equals(envelope.aggregateType()))
throw new IllegalArgumentException("尾程交接事件聚合类型无效");
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致");
FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope);
if(payload.containerNo()==null||payload.containerNo().isBlank())
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo");
@@ -70,6 +72,11 @@ public class LoadingCompletedConsumer {
inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash());
inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now());
try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) {
LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId());
if (raced == null || raced.getPayloadHash() == null
|| !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("事件摘要冲突", exception);
}
return countOrders(envelope,payload.containerNo());
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.shipping.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
import com.baiyue.common.event.RegionCode;
@@ -91,6 +93,10 @@ public class ContainerShippingNodeConsumer {
private void validate(EventEnvelope envelope) {
envelope.requireValid();
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
envelope.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致");
}
if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType())
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN
|| envelope.targetRegion() != RegionCode.OVERSEAS
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.config;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LastlegRestClientFactoryTest {
private final LastlegRestClientFactory factory = new LastlegRestClientFactory();
@Test
void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() {
LastlegRestClientProperties properties = new LastlegRestClientProperties();
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void rejectsUnboundedOrNonPositiveTimeouts() {
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void bindsPerClientTimeoutOverridesFromConfiguration() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.http.clients.unload-photo-file.connect-timeout", "125ms",
"baiyue.http.clients.unload-photo-file.read-timeout", "9s",
"baiyue.http.clients.pallet-physical.connect-timeout", "250ms",
"baiyue.http.clients.pallet-physical.read-timeout", "2s"));
LastlegRestClientProperties properties = new Binder(source)
.bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class))
.orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound"));
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9));
assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception {
var timeouts = new LastlegRestClientProperties.ClientTimeouts(
Duration.ofMillis(250), Duration.ofMillis(75));
assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/slow", exchange -> {
try { Thread.sleep(500); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
exchange.sendResponseHeaders(200, 0);
exchange.close();
});
server.start();
try {
RestClient client = factory.build(RestClient.builder(),
"http://127.0.0.1:" + server.getAddress().getPort(), timeouts);
assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity())
.isInstanceOf(ResourceAccessException.class);
} finally {
server.stop(0);
}
}
}
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
@@ -21,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
@@ -46,6 +50,17 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class);
when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED);
LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer");
assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event));
verify(loading, never()).consume(event);
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of());
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleApplicationServiceTest {
private VehicleRepository repository;
private VehicleApplicationService service;
@BeforeEach
void setUp() {
repository = mock(VehicleRepository.class);
service = new VehicleApplicationService(repository);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT));
}
@Test
void rejectsDecreasingOdometer() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
@Test
void rejectsUnknownStatus() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L)))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
private static VehicleModels.Update update(long version, long mileage) {
return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static VehicleSnapshot vehicle(long version, long mileage) {
return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null);
}
}
@@ -1,63 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleServiceTest {
private VehicleMapper mapper;
private VehicleService service;
@BeforeEach
void setUp() {
mapper = mock(VehicleMapper.class);
service = new VehicleService();
ReflectionTestUtils.setField(service, "vehicles", mapper);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409 CONFLICT");
}
@Test
void rejectsDecreasingOdometer() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
@Test
void rejectsUnknownStatus() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
private static VehicleDtos.Update update(long version, long mileage) {
return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static Vehicle vehicle(long version, long mileage) {
Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage);
value.setStatus("IN_SERVICE"); return value;
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class UnloadCompletedOutboxRepositoryTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void claimRechecksRetryWindowAfterCandidateSelection() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
LastlegEventOutbox.class);
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
assertThat(wrapper.getValue().getSqlSegment())
.contains("next_retry_at", "<=", "status", "locked_at");
}
}
@@ -8,6 +8,8 @@ class LoadingCompletedConsumerTest {
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId"));
String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"));
@@ -96,6 +96,21 @@ class ContainerShippingNodeConsumerTest {
.hasMessageContaining("摘要冲突");
}
@Test
void tamperedPayloadHashIsRejectedBeforeInboxMutation() {
EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5,
Instant.parse("2026-08-21T10:00:00Z"));
EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
source.traceId(), source.correlationId(), source.causationId(), source.payload(),
"sha256:" + "0".repeat(64));
assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payloadHash");
verify(inboxes, never()).insert(any(LastlegEventInbox.class));
}
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1");
@@ -0,0 +1,57 @@
{
"schemaVersion": 1,
"description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.",
"allowedSliceEdges": [
"dispatch-delivery->inbound-pda",
"dispatch-delivery->inventory",
"dispatch-delivery->shared-technical",
"dispatch-delivery->tailleg-handoff",
"dropship->inbound-pda",
"dropship->shared-technical",
"dropship->tailleg-handoff",
"inbound-pda->dropship",
"inbound-pda->inventory",
"inbound-pda->shared-technical",
"inbound-pda->tailleg-handoff",
"inventory->inbound-pda",
"inventory->shared-technical",
"overseas-master-data->shared-technical",
"procurement-trucking-looseboard->overseas-master-data",
"procurement-trucking-looseboard->shared-technical",
"procurement-trucking-looseboard->tailleg-handoff",
"relabel-transfer-discard->dropship",
"relabel-transfer-discard->inventory",
"relabel-transfer-discard->shared-technical",
"relabel-transfer-discard->tailleg-handoff",
"tailleg-handoff->dropship",
"tailleg-handoff->inbound-pda",
"tailleg-handoff->inventory",
"tailleg-handoff->shared-technical"
],
"requiredClassLocations": {
"com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java",
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java",
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java",
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java",
"com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java",
"com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java",
"com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java"
},
"applicationDependencyRule": {
"packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application",
"allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application",
"forbiddenImportPrefixes": [
"org.springframework.web",
"org.apache.ibatis",
"com.baomidou.mybatisplus",
"com.baiyue.lastleg.client",
"com.baiyue.lastleg.config"
]
},
"sliceSpecificRules": {
"overseas-master-data": {
"allowedOutgoing": ["shared-technical"],
"allowedIncoming": ["procurement-trucking-looseboard"]
}
}
}