feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
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 HttpVehicleCareFileGateway implements VehicleCareFileGateway {
|
||||
private static final String TYPE = "FLEET_VEHICLE_RECORD_PHOTO";
|
||||
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_VEHICLE_RECORD_PHOTO/{record}";
|
||||
private static final long MAX_SIZE = 10L * 1024 * 1024;
|
||||
private final RestClient client;
|
||||
private final String token;
|
||||
public HttpVehicleCareFileGateway(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 VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file) {
|
||||
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("运维照片须为非空 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, recordId).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, recordId, null);
|
||||
}
|
||||
@Override public VehicleCareModels.File metadata(long recordId, long fileId) {
|
||||
return verified(client.get().uri(PATH + "/{file}", recordId, fileId).header("X-Internal-Service-Token", token).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), recordId, fileId);
|
||||
}
|
||||
@Override public byte[] download(long recordId, long fileId) {
|
||||
var response = client.get().uri(PATH + "/{file}/content", recordId, 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 VehicleCareModels.File verified(Envelope result, long recordId, 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(recordId).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 VehicleCareModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
|
||||
}
|
||||
private static boolean image(String type) { return Set.of("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) { }
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baiyue.common.core.api.*;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records")
|
||||
public class PlatformVehicleCareController {
|
||||
private final VehicleCareService service;
|
||||
public PlatformVehicleCareController(VehicleCareService service) { this.service = service; }
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
|
||||
public ApiResponse<PageResult<VehicleCareModels.Response>> page(@PathVariable @Min(1) long vehicleId, @Valid VehicleCareModels.Query query) {
|
||||
return ApiResponse.success(service.page(vehicleId, query));
|
||||
}
|
||||
@GetMapping("/{recordNo}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
|
||||
public ApiResponse<VehicleCareModels.Response> detail(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo) {
|
||||
return ApiResponse.success(service.detail(vehicleId, recordNo));
|
||||
}
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-create')")
|
||||
public ApiResponse<VehicleCareModels.Response> create(@PathVariable @Min(1) long vehicleId, @AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestBody @Valid VehicleCareModels.Create request) {
|
||||
return ApiResponse.success(service.create(vehicleId, operator(user), request));
|
||||
}
|
||||
@PutMapping("/{recordNo}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-edit')")
|
||||
public ApiResponse<VehicleCareModels.Response> update(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid VehicleCareModels.Update request) {
|
||||
return ApiResponse.success(service.update(vehicleId, recordNo, operator(user), request));
|
||||
}
|
||||
@PostMapping(value = "/{recordNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-upload')")
|
||||
public ApiResponse<VehicleCareModels.Response> upload(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long recordVersion,
|
||||
@RequestParam @NotBlank @Size(max = 128) String idempotencyKey, @RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(vehicleId, recordNo, recordVersion, idempotencyKey, operator(user), file));
|
||||
}
|
||||
@GetMapping("/{recordNo}/files/{fileId}")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
|
||||
public ResponseEntity<byte[]> download(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, @PathVariable @Min(1) long fileId) {
|
||||
var file = service.download(vehicleId, recordNo, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content());
|
||||
}
|
||||
@GetMapping("/{recordNo}/history")
|
||||
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
|
||||
public ApiResponse<PageResult<VehicleCareModels.History>> history(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo,
|
||||
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
|
||||
return ApiResponse.success(service.history(vehicleId, recordNo, page, size));
|
||||
}
|
||||
private static String operator(AuthenticatedUser user) {
|
||||
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
|
||||
return user.userId().toString();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_fleet_vehicle_record")
|
||||
public class VehicleCareEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String recordNo;
|
||||
private Long vehicleId;
|
||||
private String vehiclePlate;
|
||||
private String recordType;
|
||||
private LocalDateTime occurredAt;
|
||||
private String detailsJson;
|
||||
private String filesJson;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private Long recordVersion;
|
||||
private String createdBy;
|
||||
private String updatedBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface VehicleCareFileGateway {
|
||||
VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file);
|
||||
VehicleCareModels.File metadata(long recordId, long fileId);
|
||||
byte[] download(long recordId, long fileId);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_fleet_vehicle_record_history")
|
||||
public class VehicleCareHistory {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long recordId;
|
||||
private Long recordVersion;
|
||||
private String action;
|
||||
private String reason;
|
||||
private String operatorId;
|
||||
private LocalDateTime occurredAt;
|
||||
private String snapshotJson;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface VehicleCareHistoryMapper extends BaseMapper<VehicleCareHistory> { }
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
|
||||
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}")
|
||||
VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
|
||||
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE")
|
||||
VehicleCareEntity lock(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
|
||||
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE")
|
||||
VehicleCareEntity lockCreated(@Param("vehicleId") long vehicleId, @Param("operator") String operator, @Param("key") String key);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public final class VehicleCareModels {
|
||||
private VehicleCareModels() { }
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size,
|
||||
@Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
}
|
||||
public record Draft(@NotBlank @Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType,
|
||||
@NotNull Instant occurredAt,
|
||||
@Pattern(regexp = "KM|MI") String distanceUnit,
|
||||
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal odometer,
|
||||
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal lastServiceOdometer,
|
||||
@DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal nextServiceOdometer,
|
||||
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 3) BigDecimal fuelQuantity,
|
||||
@Pattern(regexp = "L|US_GAL|IMP_GAL") String fuelUnit,
|
||||
@Pattern(regexp = "PENDING|VERIFIED") String verificationStatus,
|
||||
@Size(max = 1000) String remark, @Valid Incident incident) { }
|
||||
public record Incident(@NotBlank @Size(max = 2000) String description,
|
||||
@Size(max = 256) String item, @Size(max = 512) String place,
|
||||
@Size(max = 128) String handler,
|
||||
@NotBlank @Pattern(regexp = "PENDING|IN_PROGRESS|COMPLETED") String processingStatus,
|
||||
@Pattern(regexp = "ROUTE|TIMEOUT|DELIVERY|VEHICLE_BREAKDOWN") String exceptionType,
|
||||
@DecimalMin("0") @Digits(integer = 12, fraction = 2) BigDecimal reportedCost,
|
||||
@Pattern(regexp = "[A-Z]{3}") String currency) { }
|
||||
public record Create(@NotBlank @Size(max = 128) String idempotencyKey, @NotNull @Valid Draft details) { }
|
||||
public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason,
|
||||
@NotNull @Valid Draft details) { }
|
||||
public record File(String fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record Attached(String uploadKey, String sha256, File file) { }
|
||||
public record Response(String recordNo, String vehicleId, String vehiclePlate, Draft details,
|
||||
List<File> files, long recordVersion, String updatedBy, Instant updatedAt, String paymentStatus) { }
|
||||
public record History(long recordVersion, String action, String reason, String operatorId,
|
||||
Instant occurredAt, Response snapshot) { }
|
||||
public record Download(File metadata, byte[] content) { }
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
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.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class VehicleCareService {
|
||||
private final VehicleCareMapper records;
|
||||
private final VehicleCareHistoryMapper histories;
|
||||
private final VehicleRepository vehicles;
|
||||
private final VehicleCareFileGateway files;
|
||||
private final ObjectMapper json;
|
||||
public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories,
|
||||
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) {
|
||||
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json;
|
||||
}
|
||||
public PageResult<VehicleCareModels.Response> page(long vehicleId, VehicleCareModels.Query query) {
|
||||
vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing);
|
||||
var page = records.selectPage(new Page<>(query.page(), query.size()), new LambdaQueryWrapper<VehicleCareEntity>()
|
||||
.eq(VehicleCareEntity::getVehicleId, vehicleId)
|
||||
.eq(query.recordType() != null, VehicleCareEntity::getRecordType, query.recordType())
|
||||
.orderByDesc(VehicleCareEntity::getOccurredAt, VehicleCareEntity::getId));
|
||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
|
||||
@Transactional
|
||||
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
|
||||
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
|
||||
var vehicle = vehicles.lockById(vehicleId).orElseThrow(VehicleCareService::missing);
|
||||
String fingerprint = digest(write(request.details()).getBytes(StandardCharsets.UTF_8));
|
||||
var prior = records.lockCreated(vehicleId, operator, request.idempotencyKey());
|
||||
if (prior != null) {
|
||||
if (!prior.getRequestHash().equals(fingerprint)) throw conflict("请求标识已用于其他运维记录内容");
|
||||
return response(prior);
|
||||
}
|
||||
validate(request.details());
|
||||
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
|
||||
row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
|
||||
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
|
||||
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
|
||||
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
|
||||
row.setCreatedBy(operator); row.setUpdatedBy(operator); row.setCreatedAt(utc(Instant.now())); row.setUpdatedAt(row.getCreatedAt());
|
||||
if (records.insert(row) != 1) throw conflict("运维记录保存失败");
|
||||
record(row, "CREATE", null, operator);
|
||||
return response(row);
|
||||
}
|
||||
@Transactional
|
||||
public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) {
|
||||
var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details());
|
||||
if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型");
|
||||
if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因");
|
||||
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details()));
|
||||
persist(row, request.recordVersion(), "UPDATE", request.reason(), operator);
|
||||
return response(row);
|
||||
}
|
||||
@Transactional
|
||||
public VehicleCareModels.Response upload(long vehicleId, String number, long expectedVersion, String uploadKey, String operator, MultipartFile file) {
|
||||
var row = required(vehicleId, number, true);
|
||||
if (uploadKey == null || uploadKey.isBlank() || uploadKey.length() > 128) throw bad("附件请求标识无效");
|
||||
if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024
|
||||
|| !Set.of("image/jpeg", "image/png").contains(Objects.toString(file.getContentType(), ""))) throw bad("请选择非空 JPG 或 PNG 图片,最大 10MiB");
|
||||
String fingerprint;
|
||||
try { fingerprint = digest(file.getBytes()); } catch (IOException error) { throw bad("无法读取附件"); }
|
||||
var attached = new ArrayList<>(attached(row));
|
||||
var replay = attached.stream().filter(item -> item.uploadKey().equals(uploadKey)).findFirst();
|
||||
if (replay.isPresent()) {
|
||||
if (!replay.get().sha256().equals(fingerprint)) throw conflict("附件请求标识已用于不同图片");
|
||||
return response(row);
|
||||
}
|
||||
version(row, expectedVersion);
|
||||
if (attached.size() >= 20) throw bad("每条记录最多保存 20 张图片");
|
||||
var uploaded = files.upload(row.getId(), operator, file);
|
||||
attached.add(new VehicleCareModels.Attached(uploadKey, fingerprint, uploaded)); row.setFilesJson(write(attached));
|
||||
persist(row, expectedVersion, "UPLOAD", uploaded.fileName(), operator);
|
||||
return response(row);
|
||||
}
|
||||
public VehicleCareModels.Download download(long vehicleId, String number, long fileId) {
|
||||
var row = required(vehicleId, number, false);
|
||||
if (attached(row).stream().noneMatch(item -> item.file().fileId().equals(Long.toString(fileId)))) throw missing();
|
||||
var metadata = files.metadata(row.getId(), fileId);
|
||||
return new VehicleCareModels.Download(metadata, files.download(row.getId(), fileId));
|
||||
}
|
||||
public PageResult<VehicleCareModels.History> history(long vehicleId, String number, int page, int size) {
|
||||
var row = required(vehicleId, number, false);
|
||||
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<VehicleCareHistory>()
|
||||
.eq(VehicleCareHistory::getRecordId, row.getId()).orderByDesc(VehicleCareHistory::getRecordVersion));
|
||||
return new PageResult<>(result.getRecords().stream().map(entry -> new VehicleCareModels.History(entry.getRecordVersion(),
|
||||
entry.getAction(), entry.getReason(), entry.getOperatorId(), entry.getOccurredAt().toInstant(ZoneOffset.UTC),
|
||||
read(entry.getSnapshotJson(), VehicleCareModels.Response.class))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
private void persist(VehicleCareEntity row, long expected, String action, String reason, String operator) {
|
||||
row.setRecordVersion(expected + 1); row.setUpdatedBy(operator); row.setUpdatedAt(utc(Instant.now()));
|
||||
if (records.update(row, new LambdaUpdateWrapper<VehicleCareEntity>().eq(VehicleCareEntity::getId, row.getId())
|
||||
.eq(VehicleCareEntity::getRecordVersion, expected)) != 1) throw conflict("记录已变化,请刷新后重试");
|
||||
record(row, action, reason, operator);
|
||||
}
|
||||
private void record(VehicleCareEntity row, String action, String reason, String operator) {
|
||||
var history = new VehicleCareHistory(); history.setId(IdWorker.getId()); history.setRecordId(row.getId());
|
||||
history.setRecordVersion(row.getRecordVersion()); history.setAction(action); history.setReason(reason);
|
||||
history.setOperatorId(operator); history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(write(response(row)));
|
||||
if (histories.insert(history) != 1) throw conflict("记录历史保存失败");
|
||||
}
|
||||
private VehicleCareModels.Response response(VehicleCareEntity row) {
|
||||
return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(),
|
||||
read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(),
|
||||
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC),
|
||||
Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null);
|
||||
}
|
||||
private VehicleCareEntity required(long vehicleId, String number, boolean lock) {
|
||||
var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number);
|
||||
if (row == null) throw missing(); return row;
|
||||
}
|
||||
private static void version(VehicleCareEntity row, long expected) { if (row.getRecordVersion() != expected) throw conflict("记录已变化,请刷新后重试"); }
|
||||
static void validate(VehicleCareModels.Draft value) {
|
||||
if (value.occurredAt() == null || value.occurredAt().isAfter(Instant.now())) throw bad("记录时间不能晚于当前时间");
|
||||
if (value.occurredAt().atOffset(ZoneOffset.UTC).getYear() < 1900) throw bad("记录时间须为 1900 年及以后");
|
||||
if (Set.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION").contains(Objects.toString(value.recordType(), ""))) {
|
||||
validateIncident(value); return;
|
||||
}
|
||||
if (value.incident() != null) throw bad("保养加油不能混入维修、故障、救援或异常字段");
|
||||
if (value.odometer() == null || value.odometer().signum() < 0 || !Set.of("KM", "MI").contains(Objects.toString(value.distanceUnit(), ""))) throw bad("请填写有效里程及单位");
|
||||
if (!Set.of("PENDING", "VERIFIED").contains(Objects.toString(value.verificationStatus(), ""))) throw bad("核验状态无效");
|
||||
if ("MAINTENANCE".equals(value.recordType())) {
|
||||
if (value.lastServiceOdometer() == null || value.nextServiceOdometer() == null || value.lastServiceOdometer().signum() < 0
|
||||
|| value.lastServiceOdometer().compareTo(value.odometer()) > 0 || value.nextServiceOdometer().compareTo(value.lastServiceOdometer()) <= 0)
|
||||
throw bad("上次保养里程须不大于记录时里程,下次保养里程须大于上次保养里程");
|
||||
if (value.fuelQuantity() != null || value.fuelUnit() != null) throw bad("保养记录不能包含加油字段");
|
||||
} else if ("FUEL".equals(value.recordType())) {
|
||||
if (value.fuelQuantity() == null || value.fuelQuantity().signum() <= 0 || value.fuelUnit() == null
|
||||
|| !Set.of("L", "US_GAL", "IMP_GAL").contains(value.fuelUnit())) throw bad("请填写正数加油量并明确升、美制加仑或英制加仑");
|
||||
if (value.lastServiceOdometer() != null || value.nextServiceOdometer() != null) throw bad("加油记录不能包含保养里程字段");
|
||||
} else throw bad("记录类型无效");
|
||||
}
|
||||
private static void validateIncident(VehicleCareModels.Draft value) {
|
||||
if (value.distanceUnit() != null || value.odometer() != null || value.lastServiceOdometer() != null || value.nextServiceOdometer() != null
|
||||
|| value.fuelQuantity() != null || value.fuelUnit() != null || value.verificationStatus() != null) throw bad("处理记录不能混入保养、加油或核验字段");
|
||||
var incident = value.incident();
|
||||
if (incident == null || blank(incident.description())) throw bad("请填写事件描述");
|
||||
if (!Set.of("PENDING", "IN_PROGRESS", "COMPLETED").contains(Objects.toString(incident.processingStatus(), ""))) throw bad("处理状态无效");
|
||||
if (!"PENDING".equals(incident.processingStatus()) && blank(incident.handler())) throw bad("处理中或已完成记录须填写处理人");
|
||||
boolean costRecord = Set.of("REPAIR", "RESCUE").contains(value.recordType());
|
||||
if (costRecord && (blank(incident.item()) || blank(incident.place()))) throw bad("请填写维修或救援项目及地点");
|
||||
if (!costRecord && (incident.item() != null || incident.place() != null || incident.reportedCost() != null || incident.currency() != null))
|
||||
throw bad("故障和异常记录不接受维修救援费用字段");
|
||||
if ("EXCEPTION".equals(value.recordType())) {
|
||||
if (!Set.of("ROUTE", "TIMEOUT", "DELIVERY", "VEHICLE_BREAKDOWN").contains(Objects.toString(incident.exceptionType(), ""))) throw bad("请选择异常类型");
|
||||
} else if (incident.exceptionType() != null) throw bad("非异常记录不能设置异常类型");
|
||||
if (incident.reportedCost() != null) {
|
||||
if (incident.reportedCost().signum() < 0 || blank(incident.currency())) throw bad("费用须为非负数并填写币种");
|
||||
try { Currency.getInstance(incident.currency()); } catch (IllegalArgumentException error) { throw bad("费用币种无效"); }
|
||||
} else if (incident.currency() != null) throw bad("未登记费用时不能填写币种");
|
||||
}
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private List<VehicleCareModels.Attached> attached(VehicleCareEntity row) {
|
||||
try { return json.readValue(row.getFilesJson(), new TypeReference<>() { }); } catch (JsonProcessingException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
|
||||
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } }
|
||||
private static String digest(byte[] value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
|
||||
private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); }
|
||||
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆或当前车辆的记录不存在"); }
|
||||
private static ResponseStatusException bad(String reason) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); }
|
||||
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
|
||||
}
|
||||
Reference in New Issue
Block a user