feat(fleet): attach scoped evidence to measured trip records

This commit is contained in:
2026-10-03 16:38:37 +08:00
parent d05ff1cd26
commit e49ad96cc4
22 changed files with 584 additions and 27 deletions
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.reporting;
import org.junit.jupiter.api.Test;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.*;
class HttpTripOperationsEvidenceGatewayTest {
@Test void platformCategoryTripAndFileMustAllMatch() {
assertThat(HttpTripOperationsEvidenceGateway.verified(envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"application/pdf",8),71,81L).fileId()).isEqualTo("81");
for(var value:java.util.List.of(envelope(9,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"image/png",8),
envelope(0,"FLEET_VEHICLE_RECORD_PHOTO","71",81,"image/png",8),envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","72",81,"image/png",8),
envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",82,"image/png",8),envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"text/html",8),
envelope(0,"FLEET_TRIP_OPERATION_EVIDENCE","71",81,"image/png",10485761)))
assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.verified(value,71,81L)).hasMessageContaining("不属于");
}
@Test void contentRejectsEnvelopesAndEmptyFilesButAcceptsPdfAndImages() {
assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes()))).hasMessageContaining("不可用");
assertThatThrownBy(()->HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).body(new byte[0]))).hasMessageContaining("不可用");
for(var type:java.util.List.of(MediaType.APPLICATION_PDF,MediaType.IMAGE_PNG,MediaType.IMAGE_JPEG))
assertThat(HttpTripOperationsEvidenceGateway.imageContent(ResponseEntity.ok().contentType(type).body(new byte[]{1}))).containsExactly(1);
}
private HttpTripOperationsEvidenceGateway.Envelope envelope(long owner,String type,String trip,long file,String mime,long size){
return new HttpTripOperationsEvidenceGateway.Envelope("200",new HttpTripOperationsEvidenceGateway.Metadata(file,owner,type,trip,"meter.pdf",mime,size));
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.fleet.reporting;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.access.AccessDeniedException;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
class TripOperationsRecordAuthorizationTest {
@Test void evidenceReadsAndWritesUseSeparateOperationsPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(PlatformTripOperationsRecordController.class);
var service=context.getBean(TripOperationsRecordService.class);
when(service.download("TR-A",81)).thenReturn(new TripOperationsRecordService.Download(new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",1),new byte[]{1}));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("reader","unused",List.of(new SimpleGrantedAuthority("platform:fleet:operations:list"))));
assertThat(controller.download("TR-A",81).getHeaders().getCacheControl()).isEqualTo("no-store");
assertThatThrownBy(()->controller.upload(null,"TR-A",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.save(null,"TR-A",null)).isInstanceOf(AccessDeniedException.class);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("writer","unused",List.of(new SimpleGrantedAuthority("platform:fleet:operations:edit"))));
assertThatThrownBy(()->controller.download("TR-A",81)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.upload(null,"TR-A",null)).hasMessageContaining("仅平台人员");
verify(service).download("TR-A",81);verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
@Configuration @EnableMethodSecurity
static class Config {
@Bean TripOperationsRecordService service(){return mock(TripOperationsRecordService.class);}
@Bean PlatformTripOperationsRecordController controller(TripOperationsRecordService service){return new PlatformTripOperationsRecordController(service);}
}
}
@@ -17,16 +17,20 @@ import static org.mockito.Mockito.*;
class TripOperationsRecordPersistenceTest {
private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service;
private com.baiyue.lastleg.fleet.trip.FleetTripMapper trips;
private TripOperationsEvidenceGateway files;
@BeforeEach void setup() throws Exception {
var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source);
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)");
jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)");
var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule()));
trips=mock(com.baiyue.lastleg.fleet.trip.FleetTripMapper.class);files=mock(TripOperationsEvidenceGateway.class);
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setId(71L);trip.setTripNo("TR-A");trip.setDepartedAt(java.time.LocalDateTime.of(2026,10,1,0,0));when(trips.findByNo("TR-A")).thenReturn(trip);
repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule()),trips,files);
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy();
}
private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);}
private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Input(distance,fuel,null,java.util.List.of()),null);}
@Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() {
assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero();
assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车");
@@ -53,4 +57,43 @@ class TripOperationsRecordPersistenceTest {
assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero();
}
private TripOperationsRecordModels.Save evidence(long version,String key,Long... ids){
return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Input(null,null,null,java.util.List.of(ids)),null);
}
@Test void optionalEvidenceIsValidatedAndFrozenWithHistoryAndExactReplay() {
var file=new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",8);
when(files.metadata(71,81)).thenReturn(file);
var command=evidence(0,"attach-proof",81L);var saved=service.save("TR-A","1",command);
assertThat(saved.details().evidenceFiles()).containsExactly(file);assertThat(saved.recordVersion()).isEqualTo(1);
service.save("TR-A","1",evidence(1,"clear-proof"));
assertThat(service.get("TR-A").details().evidenceFiles()).isEmpty();
reset(files);
assertThat(service.save("TR-A","1",command).details().evidenceFiles()).containsExactly(file);
verifyNoInteractions(files);
assertThat(service.history("TR-A",1,20).records().get(1).details().evidenceFiles()).containsExactly(file);
assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"attach-proof",82L))).hasMessageContaining("其他实测内容");
assertThatThrownBy(()->service.save("TR-A","1",evidence(1,"stale-proof",81L))).hasMessageContaining("已变化");
}
@Test void duplicateOrForeignEvidenceCannotCreateRecordAndEmptyEvidenceIsLegal() {
assertThat(service.save("TR-A","1",evidence(0,"empty-evidence")).recordVersion()).isZero();
assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"duplicate-evidence",81L,81L))).hasMessageContaining("不同");
when(files.metadata(71,81)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"文件不属于当前车次"));
assertThatThrownBy(()->service.save("TR-A","1",evidence(0,"foreign-evidence",81L))).hasMessageContaining("不属于");
assertThat(repository.count("TR-A")).isZero();
}
@Test void uploadDoesNotCreateMeasuredRecordAndHistoricalDownloadRevalidatesOwnership() {
var blob=new org.springframework.mock.web.MockMultipartFile("file","meter.pdf","application/pdf",new byte[]{1});
var file=new TripOperationsRecordModels.EvidenceFile("81","meter.pdf","application/pdf",1);
when(files.upload(71,"1",blob)).thenReturn(file);
assertThat(service.upload("TR-A","1",blob)).isEqualTo(file);assertThat(repository.count("TR-A")).isZero();
when(files.upload(71,"1",blob)).thenThrow(new IllegalStateException("upload interrupted"));
assertThatThrownBy(()->service.upload("TR-A","1",blob)).hasMessageContaining("interrupted");assertThat(repository.count("TR-A")).isZero();
assertThatThrownBy(()->service.download("TR-A",81)).hasMessageContaining("尚未保存");
when(files.metadata(71,81)).thenReturn(file);when(files.download(71,81)).thenReturn(new byte[]{1});
service.save("TR-A","1",evidence(0,"bind-for-read",81L));
service.save("TR-A","1",evidence(1,"remove-reference"));
assertThat(service.download("TR-A",81).content()).containsExactly(1);
assertThatThrownBy(()->service.download("TR-A",82)).hasMessageContaining("尚未保存");
assertThatThrownBy(()->service.upload("TR-B","1",blob)).hasMessageContaining("实际已发车");
}
}