feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class HttpVehicleCareFileGatewayTest {
@Test void aSuccessfulHttpEnvelopeCannotBeReturnedAsImageBytes() {
var json = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes());
assertThatThrownBy(() -> HttpVehicleCareFileGateway.imageContent(json)).hasMessageContaining("不可用");
var image = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[]{1, 2, 3});
assertThat(HttpVehicleCareFileGateway.imageContent(image)).containsExactly(1, 2, 3);
}
@Test void metadataMustMatchPlatformRecordCategoryAndFileIdentity() {
assertThat(HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L).fileId()).isEqualTo("81");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(9, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_DRIVER_LICENSE", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "72", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 82, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "text/html"), 71, 81L)).hasMessageContaining("有效照片");
}
private HttpVehicleCareFileGateway.Envelope envelope(long owner, String type, String record, long file, String mime) {
return new HttpVehicleCareFileGateway.Envelope("200", new HttpVehicleCareFileGateway.Metadata(file, owner, type, record, "meter.png", mime, 8L));
}
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class VehicleCareApiContractTest {
@Test void operationsPermissionsAndSchemasMatchTheirPublishedContracts() throws Exception {
Map<String,Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-records-v1.yaml")));
Map<?,?> paths = (Map<?,?>)document.get("paths");
String permissionSql = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
String initialization = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
String base = PlatformVehicleCareController.class.getAnnotation(RequestMapping.class).value()[0];
int count = 0;
for (var method : PlatformVehicleCareController.class.getDeclaredMethods()) {
String verb = null; String[] suffix = null;
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); }
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); }
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); }
if (verb == null) continue;
String path = base + (suffix.length == 0 ? "" : suffix[0]);
assertThat(paths.containsKey(path)).as(path).isTrue();
Map<?,?> operation = (Map<?,?>)((Map<?,?>)paths.get(path)).get(verb);
String expression = method.getAnnotation(PreAuthorize.class).value();
String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\''));
assertThat(operation.get("description").toString()).contains(permission);
assertThat(permissionSql).contains("'" + permission + "'"); assertThat(initialization).contains("'" + permission + "'"); count++;
}
assertThat(count).isEqualTo(7);
for (String table : List.of("ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) {
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
var module = pattern.matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployed = pattern.matcher(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
assertThat(module.find()).isTrue(); assertThat(deployed.find()).isTrue();
assertThat(module.group().replace("\r\n", "\n")).isEqualTo(deployed.group().replace("\r\n", "\n"));
}
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleCareAuthorizationTest {
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void vehicleListReadsRecordsButDoesNotAuthorizeCreateEditOrPhotoUpload() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformVehicleCareController.class);
var service = context.getBean(VehicleCareService.class);
authenticate("platform:overseas-master-data:vehicle:list");
controller.detail(1, "VR-1"); verify(service).detail(1, "VR-1");
assertThatThrownBy(() -> controller.create(1, null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.update(1, "VR-1", null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.upload(1, "VR-1", null, 0, "key", null)).isInstanceOf(AccessDeniedException.class);
authenticate("platform:overseas-master-data:driver:list");
assertThatThrownBy(() -> controller.detail(1, "VR-1")).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.download(1, "VR-1", 1)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
}
private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); }
@Configuration @EnableMethodSecurity static class Config {
@Bean VehicleCareService service() { return mock(VehicleCareService.class); }
@Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); }
}
}
@@ -0,0 +1,166 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class VehicleCarePersistenceTest {
private JdbcTemplate jdbc;
private VehicleCareService service;
private VehicleCareFileGateway files;
private long vehicleA, vehicleB;
private final Instant occurred = Instant.parse("2026-01-01T00:00:00Z");
@BeforeEach void setup() throws Exception {
var source = new DriverManagerDataSource("jdbc:h2:mem:care-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(source);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String table : List.of("ll_vehicle", "ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
}
var config = new MybatisConfiguration();
config.addMapper(VehicleMapper.class); config.addMapper(VehicleCareMapper.class); config.addMapper(VehicleCareHistoryMapper.class);
var interceptor = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
config.addInterceptor(interceptor);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
var session = new SqlSessionTemplate(factory.getObject());
var vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class));
vehicleA = vehicles.create(vehicle("VEH-A")); vehicleB = vehicles.create(vehicle("VEH-B"));
files = mock(VehicleCareFileGateway.class);
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
new ObjectMapper().registerModule(new JavaTimeModule()));
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (VehicleCareService) proxy.getProxy();
}
@Test void recordsAreScopedToVehicleAndNeverRewriteMasterOdometerOrCreateAnotherRecordOnRetry() {
var request = new VehicleCareModels.Create("key", maintenance());
var a = service.create(vehicleA, "1", request);
assertThat(service.create(vehicleA, "1", request).recordNo()).isEqualTo(a.recordNo());
var b = service.create(vehicleB, "1", request);
assertThat(b.recordNo()).isNotEqualTo(a.recordNo());
assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, "MAINTENANCE")).records()).extracting(VehicleCareModels.Response::recordNo).containsExactly(a.recordNo());
assertThatThrownBy(() -> service.detail(vehicleB, a.recordNo())).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.update(vehicleB, a.recordNo(), "1", new VehicleCareModels.Update(0L, "错误车辆", maintenance()))).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.download(vehicleB, a.recordNo(), 9001)).hasMessageContaining("不存在");
assertThat(jdbc.queryForList("SELECT mileage_km FROM ll_vehicle", Long.class)).containsOnly(1000L);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(2);
}
@Test void correctionKeepsImmutableHistoryAndRejectsStaleVersionOrChangedType() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", maintenance()));
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()))).hasMessageContaining("其他运维记录");
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "变更类型", fuel()))).hasMessageContaining("不能改变");
service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "补充核对", maintenance()));
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "旧页面", maintenance()))).hasMessageContaining("已变化");
var history = service.history(vehicleA, row.recordNo(), 1, 20).records();
assertThat(history).extracting(VehicleCareModels.History::action).containsExactly("UPDATE", "CREATE");
assertThat(history.get(1).snapshot().updatedBy()).isEqualTo("1");
}
@Test void failedHistoryWriteRollsBackUpdate() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()));
jdbc.execute("ALTER TABLE ll_fleet_vehicle_record_history ADD CONSTRAINT fail_update CHECK(action <> 'UPDATE')");
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "更正", fuel()))).isInstanceOf(RuntimeException.class);
assertThat(service.detail(vehicleA, row.recordNo()).recordVersion()).isZero();
assertThat(service.detail(vehicleA, row.recordNo()).updatedBy()).isEqualTo("1");
}
@Test void uploadRetryReturnsOriginalAssociationAndRejectsAnotherImageOrForeignRecord() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()));
var image = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1,2,3});
var uploaded = service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image);
assertThat(uploaded.files()).hasSize(1);
assertThat(service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image).recordVersion()).isEqualTo(1);
verify(files, times(1)).upload(anyLong(), eq("1"), any());
assertThatThrownBy(() -> service.upload(vehicleA, row.recordNo(), 1, "upload-key", "1",
new MockMultipartFile("file", "other.png", "image/png", new byte[]{4}))).hasMessageContaining("不同图片");
assertThatThrownBy(() -> service.upload(vehicleB, row.recordNo(), 1, "new-key", "1", image)).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.download(vehicleA, row.recordNo(), 9999)).hasMessageContaining("不存在");
verify(files, never()).metadata(anyLong(), anyLong());
}
@Test void explicitUnitsAndTypedFieldsAreRequiredButHistoricalOdometersMayBeBelowTheMaster() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("fuel", fuel()));
assertThat(row.details().distanceUnit()).isEqualTo("MI");
assertThat(row.details().fuelUnit()).isEqualTo("US_GAL");
assertThat(row.details().odometer()).isEqualByComparingTo("800");
var bad = new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("10"), "GAL", "PENDING", null, null);
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("bad", bad))).hasMessageContaining("明确");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1);
}
@Test void concurrentDuplicateCreationCreatesOneBusinessRecordAndOneHistory() throws Exception {
var request = new VehicleCareModels.Create("concurrent", fuel());
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
Callable<String> task = () -> { start.await(5, TimeUnit.SECONDS); return service.create(vehicleA, "1", request).recordNo(); };
try {
var first = executor.submit(task); var second = executor.submit(task); start.countDown();
assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(1);
} finally { executor.shutdownNow(); }
}
@Test void incidentTypesKeepProjectsPlacesAndProcessingFactsIsolatedPerVehicle() {
for (String type : List.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION")) {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create(type, incident(type, "PENDING", null, null, null)));
assertThat(service.detail(vehicleA, row.recordNo()).details().incident()).isEqualTo(row.details().incident());
assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, type)).records()).hasSize(1);
assertThat(service.page(vehicleB, new VehicleCareModels.Query(1, 20, type)).total()).isZero();
assertThatThrownBy(() -> service.update(vehicleB, row.recordNo(), "1", new VehicleCareModels.Update(0L, "更新处理", row.details()))).hasMessageContaining("不存在");
if (List.of("REPAIR", "RESCUE").contains(type)) {
assertThat(row.details().incident().item()).isEqualTo("更换轮胎");
assertThat(row.details().incident().place()).isEqualTo("实际作业地点");
assertThat(row.paymentStatus()).isEqualTo("UNLINKED");
}
}
}
@Test void completedRepairDoesNotInventPaymentOrReleaseAndChangingCostKeepsHistory() {
var created = service.create(vehicleA, "1", new VehicleCareModels.Create("repair", incident("REPAIR", "PENDING", null, null, null)));
var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD")));
var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD")));
assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0");
assertThat(completed.paymentStatus()).isEqualTo("UNLINKED");
assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE");
var history = service.history(vehicleA, created.recordNo(), 1, 20).records();
assertThat(history).hasSize(3);
assertThat(history.get(1).snapshot().details().incident().reportedCost()).isEqualByComparingTo("120.50");
assertThat(history.get(2).snapshot().details().incident().reportedCost()).isNull();
assertThatThrownBy(() -> service.update(vehicleA, created.recordNo(), "1", new VehicleCareModels.Update(0L, "旧请求", created.details()))).hasMessageContaining("已变化");
}
@Test void incidentValidationRejectsMissingHandlersCurrencyAndMixedFuelFields() {
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-handler", incident("RESCUE", "COMPLETED", null, null, null)))).hasMessageContaining("处理人");
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-currency", incident("RESCUE", "PENDING", null, BigDecimal.ZERO, null)))).hasMessageContaining("币种");
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("invalid-currency", incident("RESCUE", "PENDING", null, BigDecimal.ONE, "ZZZ")))).hasMessageContaining("币种无效");
var detail = incident("EXCEPTION", "PENDING", null, null, null);
var mixed = new VehicleCareModels.Draft(detail.recordType(), occurred, "KM", BigDecimal.ONE, null, null, null, null, null, null, detail.incident());
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("mixed", mixed))).hasMessageContaining("不能混入");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isZero();
}
private VehicleCareModels.Draft incident(String type, String status, String handler, BigDecimal cost, String currency) {
boolean priced = List.of("REPAIR", "RESCUE").contains(type);
return new VehicleCareModels.Draft(type, occurred, null, null, null, null, null, null, null, null,
new VehicleCareModels.Incident("现场轮胎故障及处理情况", priced ? "更换轮胎" : null, priced ? "实际作业地点" : null,
handler, status, "EXCEPTION".equals(type) ? "VEHICLE_BREAKDOWN" : null, cost, currency));
}
private VehicleCareModels.Draft maintenance() { return new VehicleCareModels.Draft("MAINTENANCE", occurred, "KM", new BigDecimal("800"), new BigDecimal("500"), new BigDecimal("700"), null, null, "PENDING", "已超过计划保养里程", null); }
private VehicleCareModels.Draft fuel() { return new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("28.6"), "US_GAL", "PENDING", null, null); }
private VehicleRepository.NewVehicle vehicle(String plate) { return new VehicleRepository.NewVehicle(plate, "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), 30, new BigDecimal("18000"), new BigDecimal("90")); }
}