feat(fleet): attach scoped evidence to measured trip records
This commit is contained in:
+71
@@ -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) { }
|
||||
}
|
||||
+20
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
+11
-2
@@ -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) { }
|
||||
}
|
||||
|
||||
+39
-5
@@ -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);}}
|
||||
|
||||
Reference in New Issue
Block a user