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
+1 -1
View File
@@ -76,7 +76,7 @@
## 海外仓全流程开发路由
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
@Component
public class HttpTripOperationsEvidenceGateway implements TripOperationsEvidenceGateway {
private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE";
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_TRIP_OPERATION_EVIDENCE/{trip}";
private static final long MAX_SIZE = 10L * 1024 * 1024;
private final RestClient client;
private final String token;
public HttpTripOperationsEvidenceGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties,
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl,
@Value("${baiyue.security.internal-service-token:}") String token) {
client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token;
}
@Override public TripOperationsRecordModels.EvidenceFile upload(long tripId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("实测凭证须为非空 PDF、JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try { body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(MediaType.parseMediaType(file.getContentType())); }
catch (IOException error) { throw bad("无法读取实测凭证"); }
var result = client.post().uri(PATH, tripId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class);
return verified(result, tripId, null);
}
@Override public TripOperationsRecordModels.EvidenceFile metadata(long tripId, long fileId) {
return verified(client.get().uri(PATH + "/{file}", tripId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), tripId, fileId);
}
@Override public byte[] download(long tripId, long fileId) {
var response = client.get().uri(PATH + "/{file}/content", tripId, fileId).header("X-Internal-Service-Token", token).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class);
return imageContent(response);
}
static byte[] imageContent(ResponseEntity<byte[]> response) {
byte[] content = response.getBody();
if (content == null || content.length == 0 || content.length > MAX_SIZE
|| !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
return content;
}
static TripOperationsRecordModels.EvidenceFile verified(Envelope result, long tripId, Long fileId) {
if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);
var data = result.data();
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(tripId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前车次或不是有效凭证");
return new TripOperationsRecordModels.EvidenceFile(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
}
private static boolean image(String type) { return Set.of("application/pdf", "image/jpeg", "image/png").contains(Objects.toString(type, "")); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "实测凭证不可用或归属校验失败"); }
record Envelope(String code, Metadata data) { }
record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { }
}
@@ -10,19 +10,38 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ContentDisposition;
@RestController @Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record")
public class PlatformTripOperationsRecordController {
private final TripOperationsRecordService service;
public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;}
@PostMapping(value="/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:fleet:operations:edit')")
public ApiResponse<TripOperationsRecordModels.EvidenceFile> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@RequestPart("file") MultipartFile file){
return ApiResponse.success(service.upload(tripNo,operator(user),file));
}
@GetMapping("/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ResponseEntity<byte[]> download(@PathVariable String tripNo,@PathVariable @Positive long fileId){
var value=service.download(tripNo,fileId);
return ResponseEntity.ok().contentType(MediaType.parseMediaType(value.file().contentType()))
.header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.inline().filename(value.file().fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()).body(value.content());
}
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<TripOperationsRecordModels.Record> get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));}
@GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<PageResult<TripOperationsRecordModels.History>> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));}
@PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')")
public ApiResponse<TripOperationsRecordModels.Record> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){
return ApiResponse.success(service.save(tripNo,operator(user),request));
}
private static String operator(AuthenticatedUser user){
if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录");
return ApiResponse.success(service.save(tripNo,user.userId().toString(),request));
return user.userId().toString();
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.reporting;
import org.springframework.web.multipart.MultipartFile;
public interface TripOperationsEvidenceGateway {
TripOperationsRecordModels.EvidenceFile upload(long tripId,String operator,MultipartFile file);
TripOperationsRecordModels.EvidenceFile metadata(long tripId,long fileId);
byte[] download(long tripId,long fileId);
}
@@ -4,16 +4,25 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public final class TripOperationsRecordModels {
private TripOperationsRecordModels() { }
public record Details(
@DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles,
@DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres,
@Size(max=1000) String remark) { }
@Size(max=1000) String remark, List<EvidenceFile> evidenceFiles) {
public Details { evidenceFiles=evidenceFiles==null?List.of():List.copyOf(evidenceFiles); }
}
public record EvidenceFile(String fileId,String fileName,String contentType,long sizeBytes) { }
public record Input(@DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualDistanceMiles,
@DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualFuelLitres,
@Size(max=1000) String remark,@Size(max=20) List<@NotNull @Positive Long> evidenceFileIds) {
public Input { evidenceFileIds=evidenceFileIds==null?List.of():List.copyOf(evidenceFileIds); }
}
public record Save(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotNull @Valid Details details, @Size(max=1000) String reason) { }
@NotNull @Valid Input details, @Size(max=1000) String reason) { }
public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { }
public record History(long recordVersion, String reason, Instant occurredAt, Details details) { }
}
@@ -13,13 +13,40 @@ import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.*;
import java.util.HexFormat;
import java.util.List;
import java.util.HashSet;
import com.baiyue.lastleg.fleet.trip.FleetTripMapper;
import org.springframework.web.multipart.MultipartFile;
/** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */
@Service
public class TripOperationsRecordService {
private final TripOperationsRecordRepository repository;
private final ObjectMapper json;
public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json) { this.repository=repository;this.json=json; }
private final FleetTripMapper trips;
private final TripOperationsEvidenceGateway files;
public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json,FleetTripMapper trips,TripOperationsEvidenceGateway files) { this.repository=repository;this.json=json;this.trips=trips;this.files=files; }
@Transactional(readOnly=true)
public TripOperationsRecordModels.EvidenceFile upload(String tripNo,String operator,MultipartFile file) {
return files.upload(evidenceTripId(tripNo),operator,file);
}
public record Download(TripOperationsRecordModels.EvidenceFile file,byte[] content) { }
@Transactional(readOnly=true)
public Download download(String tripNo,long fileId) {
long tripId=evidenceTripId(tripNo);
boolean referenced=false;
long total=repository.count(tripNo);
for(int page=1;(long)(page-1)*100<total && !referenced;page++) {
referenced=repository.history(tripNo,page,100).stream().anyMatch(row->read(row.details()).evidenceFiles().stream().anyMatch(file->Long.toString(fileId).equals(file.fileId())));
}
if(!referenced)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"凭证尚未保存到本车实测记录或历史版本");
return new Download(files.metadata(tripId,fileId),files.download(tripId,fileId));
}
private long evidenceTripId(String tripNo) {
var trip=trips.findByNo(tripNo);
if(trip==null || trip.getDepartedAt()==null)throw conflict("仅实际已发车的车次可维护实测凭证");
return trip.getId();
}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public TripOperationsRecordModels.Record get(String tripNo) {
requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo));
@@ -33,21 +60,28 @@ public class TripOperationsRecordService {
@Transactional(isolation=Isolation.READ_COMMITTED)
public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) {
requireDeparture(tripNo,true);
var d=request.details();var details=new TripOperationsRecordModels.Details(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark()));
var d=request.details();
if(d.evidenceFileIds().size()>20 || new HashSet<>(d.evidenceFileIds()).size()!=d.evidenceFileIds().size()
|| d.evidenceFileIds().stream().anyMatch(id->id==null||id<=0))throw conflict("每次最多绑定20个不同的实测凭证");
var input=new TripOperationsRecordModels.Input(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark()),d.evidenceFileIds());
String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录";
String contents=write(details);String hash=hash(request.recordVersion()+":"+contents+":"+reason);
String hash=hash(request.recordVersion()+":"+write(input)+":"+reason);
var replay=repository.replay(tripNo,operator,request.idempotencyKey());
if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);}
var previous=repository.latest(tripNo);long version=previous==null?0:previous.version();
if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正");
long tripId=input.evidenceFileIds().isEmpty()?0:evidenceTripId(tripNo);
var evidence=input.evidenceFileIds().stream().map(fileId->files.metadata(tripId,fileId)).toList();
var details=new TripOperationsRecordModels.Details(input.actualDistanceMiles(),input.actualFuelLitres(),input.remark(),evidence);
String contents=write(details);
if(previous!=null && previous.details().equals(contents))return response(tripNo,previous);
if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null)return response(tripNo,null);
if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null && details.evidenceFiles().isEmpty())return response(tripNo,null);
var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC));
repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row);
}
private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");}
private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) {
return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null),null)
return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null,List.of()),null)
:new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC));
}
private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}}
@@ -320,7 +320,7 @@
}
},
"put": {
"description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。",
"description": "platform:fleet:operations:edit;父车次行锁、版本与原操作人请求键校验,原请求返回原快照,更正追加不可变历史。空值区别零,说明选填。不修改主档里程、车次执行、库存或费用。;附件选填,最多20个,保存前验证平台0、FLEET_TRIP_OPERATION_EVIDENCE和本车trip.id归属;幂等键包含完整文件ID列表,成功重放返回原快照,移除当前引用保留历史。",
"requestBody": {
"required": true,
"content": {
@@ -338,7 +338,7 @@
"maxLength": 128
},
"details": {
"$ref": "#/components/schemas/Measured"
"$ref": "#/components/schemas/MeasuredInput"
},
"reason": {
"type": "string",
@@ -480,6 +480,115 @@
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/files": {
"parameters": [
{
"name": "tripNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"summary": "上传选填实测凭证,不创建实测记录",
"description": "platform:fleet:operations:edit;仅平台0,实际已发车车次;FLEET_TRIP_OPERATION_EVIDENCE+trip.id,PDF/JPG/PNG,非空且最大10MiB;上传失败不创建或重建记录。",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"responses": {
"200": {
"description": "统一响应data为可信文件信息",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/OperationEvidenceFile"
}
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/{tripNo}/record/files/{fileId}/content": {
"parameters": [
{
"name": "tripNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "fileId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1
}
}
],
"get": {
"summary": "按需读取本车实测凭证,历史引用继续可读",
"description": "platform:fleet:operations:list;仅已保存至本车当前或历史版本的引用可读;重验平台0/业务类型/车次内部ID/文件ID,no-store;不逐行预取。",
"responses": {
"200": {
"description": "PDF或图片原始内容",
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "文件不可用或归属不符"
}
}
}
}
},
"components": {
@@ -695,12 +804,19 @@
"type": "string",
"nullable": true,
"maxLength": 1000
},
"evidenceFiles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OperationEvidenceFile"
}
}
},
"required": [
"actualDistanceMiles",
"actualFuelLitres",
"remark"
"remark",
"evidenceFiles"
]
},
"Destination": {
@@ -918,6 +1034,74 @@
"unallocatedTrips",
"totals"
]
},
"MeasuredInput": {
"type": "object",
"properties": {
"actualDistanceMiles": {
"type": "number",
"minimum": 0,
"nullable": true,
"multipleOf": 0.1
},
"actualFuelLitres": {
"type": "number",
"minimum": 0,
"nullable": true,
"multipleOf": 0.1
},
"remark": {
"type": "string",
"nullable": true,
"maxLength": 1000
},
"evidenceFileIds": {
"type": "array",
"maxItems": 20,
"uniqueItems": true,
"items": {
"type": "integer",
"format": "int64",
"minimum": 1
},
"description": "选填,空数组合法;只提交本车已上传文件ID,保存重新核验归属"
}
},
"required": [
"actualDistanceMiles",
"actualFuelLitres",
"remark"
]
},
"OperationEvidenceFile": {
"type": "object",
"required": [
"fileId",
"fileName",
"contentType",
"sizeBytes"
],
"properties": {
"fileId": {
"type": "string"
},
"fileName": {
"type": "string"
},
"contentType": {
"type": "string",
"enum": [
"application/pdf",
"image/jpeg",
"image/png"
]
},
"sizeBytes": {
"type": "integer",
"minimum": 1,
"maximum": 10485760
}
}
}
}
},
@@ -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("实际已发车");
}
}