This commit is contained in:
2026-10-20 15:48:38 +08:00
parent da6b4685d7
commit 0ff1eefff6
50 changed files with 3933 additions and 78 deletions
@@ -47,6 +47,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership;
/** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -522,6 +523,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
// 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。
// 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。
ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(),
context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at);
return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed));
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
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;
/** 只读原排车装车后事实,仅附加登记和照片可写。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/owned-business")
public class OwnedBusinessController {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessSupport access;
/** Controller不直调Repository/Mapper,身份只取会话。 */
public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
this.queries=queries;this.records=records;this.photos=photos;this.access=access;
}
/** 列表和完整统计一次返回。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));}
/** list权限包含独立详情。 */
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));}
/** 原排车和附加历史在同一快照分页,不逐条GET。 */
@GetMapping("/{dispatchNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
return ApiResponse.success(queries.history(access.actor(user),dispatchNo,page,size));
}
/** 金额可以更正/清空,但不推进主单或触发财务。 */
@PutMapping("/{dispatchNo}/operations")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
return ApiResponse.success(records.save(access.actor(user),dispatchNo,request));
}
/** 上传仅准备File,关联保存另有独立原键。 */
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file));
}
/** 绑定失败保留fileId和原请求,不重新上传。 */
@PutMapping("/{dispatchNo}/photos/{slot}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')")
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request));
}
/** 附件只允许当前或精确历史引用读取。 */
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
var result=photos.content(access.actor(user),dispatchNo,slot,fileId,revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.content());
}
/** 不自动补写或向别的账号披露原命令。 */
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:fleet:owned-business:list')")
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey));
}
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
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 java.time.LocalDateTime;
import java.util.Set;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** File保存照片,本域另行授权原排车及分类引用。 */
@Component
public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway {
public static final long MAX_SIZE=10L*1024*1024;
private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}";
private final RestClient client;
private final String token;
/** 沿既有内部File客户端,保持外部访问域封闭。 */
public HttpOwnedBusinessFileGateway(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;
}
/** MIME和字节签名一起校验,File仍执行其权威校验。 */
public static void validate(Upload upload) {
if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255
|| upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE
|| !image(upload.contentType()) || !signature(upload.contentType(),upload.content()))
throw bad("照片须为非空真实JPG/PNG,最大10MiB");
}
/** 使用原图片字节,未知结果不能换键或自动重传。 */
@Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) {
validate(upload);var body=new MultipartBodyBuilder();
body.part("file",new ByteArrayResource(upload.content()){
/** 保留本轮原文件名,不生成占位证据。 */
@Override public String getFilename(){return upload.fileName();}
}).contentType(MediaType.parseMediaType(upload.contentType()));
var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token)
.header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class);
return verified(result,dispatchId,slot,null);
}
/** 不能借另一客户、另一分类或另一排车的文件授权。 */
@Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) {
return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId);
}
/** 防止把错误页或其他内容冒充图片返回。 */
@Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) {
var info=metadata(dispatchId,slot,fileId);
byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId)
.header("X-Internal-Service-Token",token).retrieve().body(byte[].class);
if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致");
return bytes;
}
/** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */
public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) {
if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功");
var m=response.data();
if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType())
|| !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId())
|| m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType())
|| m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效");
return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString());
}
/** 不放开PDF、SVG或任意image类型。 */
private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);}
/** 使用真实文件头,不转换或伪造证据图片。 */
private static boolean signature(String type,byte[] bytes) {
if(bytes==null)return false;
if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255;
byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length<png.length)return false;
for(int i=0;i<png.length;i++)if(bytes[i]!=png[i])return false;return true;
}
public record Envelope(String code,Metadata data) {}
public record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes,LocalDateTime createdAt) {}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.ownedbusiness.gateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
/** 本页平台图片受控代理,不接收浏览器指定的内部主键或业务类型。 */
public interface OwnedBusinessFileGateway {
record Upload(String fileName,String contentType,byte[] content) {}
record Evidence(long fileId,String fileName,String contentType,long sizeBytes,String uploadedAt) {}
/** 同一原意图只上传一次,未知结果由账本保存。 */
Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload);
/** 核验平台、原排车、分类、文件身份和图像格式。 */
Evidence metadata(long dispatchId,PhotoSlot slot,long fileId);
/** 只代理业务层已确认当前或历史引用的内容。 */
byte[] content(long dispatchId,PhotoSlot slot,long fileId);
}
@@ -0,0 +1,88 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 本页4表唯一SQL入口;不更新排车、库存或财务表。 */
@Mapper
public interface OwnedBusinessMapper {
/** 读取已经冻结的装车归属,不查询当前车辆推断历史。 */
@Select("SELECT * FROM ll_fleet_owned_business_ownership WHERE dispatch_id=#{id}")
Ownership ownership(long id);
/** 在主装车事务保存唯一归属证据,重复交由提供者核对原事实。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_ownership(dispatch_id,vehicle_id,resource_mode,vehicle_ownership,
vehicle_record_version,ownership_captured_at,loading_completion_hash,loading_completed_at)
VALUES(#{dispatchId},#{vehicleId},#{resourceMode},#{vehicleOwnership},#{vehicleRecordVersion},
#{ownershipCapturedAt},#{loadingCompletionHash},#{loadingCompletedAt})
""")
int capture(Ownership row);
/** GET不使用此方法;首个写事务建立可锁定的附加0版本行。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_record(id,dispatch_id,record_version,arrivals_json,photos_json,
created_by,updated_by,created_at,updated_at) VALUES(#{id},#{dispatchId},0,'[]','[]',
#{createdBy},#{updatedBy},#{createdAt},#{updatedAt}) ON DUPLICATE KEY UPDATE dispatch_id=dispatch_id
""")
int ensure(Record row);
/** 只锁本页附加行,不反向申请车辆或派送写锁。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id} FOR UPDATE")
Record lockRecord(long id);
/** 详情只读附加信息,无记录返回虚拟0版本。 */
@Select("SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id=#{id}")
Record record(long id);
/** 页内批量读取,避免逐车补充GET和SQL。 */
@Select("<script>SELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Record> records(@Param("ids") List<Long> ids);
/** CAS仅修改运营附加字段,不占用主单recordVersion。 */
@Update("""
UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency},
total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles},
fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson},
photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt}
WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected}
""")
int update(@Param("r") Record row,@Param("expected") long expected);
/** 命令键全车唯一,操作者在服务端另行核验。 */
@Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}")
Command command(@Param("id") long id,@Param("key") String key);
/** 完整意图在文件HTTP之前单独事务提交。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action,
expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at)
VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion},
#{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt})
""")
int insertCommand(Command row);
/** 上传只完成原待定意图,不能把未知结果当新命令重做。 */
@Update("""
UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson},
error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING'
""")
int finishUpload(Command row);
/** 附加写与不可变历史同事务,版本不能重复。 */
@Insert("""
INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id,
reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId},
#{reason},#{snapshotJson},#{occurredAt})
""")
int insertHistory(History row);
/** 历史按本页版本顺序分页。 */
@Select("""
SELECT * FROM (
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
UNION ALL
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
""")
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
/** 历史总数不取当前页长度。 */
@Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})")
int historyCount(long id);
/** 历史文件只能从指定真实历史快照读取。 */
@Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}")
History revision(@Param("id") long id,@Param("version") long version);
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.fleet.ownedbusiness.mapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
String SOURCE="""
SELECT d.*,o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version,
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
""";
String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.warehouseId != null'> AND d.overseas_warehouse_id=#{q.warehouseId}</if>
<if test='q.keyword != null'> AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%')
OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i
WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%')
OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%'))))</if>
""";
/** 只返回当前完整条件下的一车一行。 */
@Select("<script>"+SOURCE+" WHERE "+FILTER+" ORDER BY d.planned_departure_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
List<Source> page(@Param("q") Search query);
/** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */
@Select("<script>SELECT d.stage,COUNT(*) AS quantity FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id WHERE "+FILTER+" GROUP BY d.stage</script>")
List<StageCount> summary(@Param("q") Search query);
/** 先读原主单和可空归属,以明确区别无快照与不存在。 */
@Select(SOURCE+" WHERE d.dispatch_no=#{number}")
Source find(String number);
/** 页内全部货物一次读取,各客户保持原归属。 */
@Select("<script>SELECT * FROM ll_dispatch_item WHERE dispatch_order_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY dispatch_order_id,id</script>")
List<DispatchItem> cargo(@Param("ids") List<Long> ids);
/** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */
@Select("""
<script>SELECT a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,
p.appointment_start_utc AS start_utc,SUM(a.package_count) AS allocated_packages
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_item i
ON i.id=a.dispatch_item_id AND i.dispatch_order_id=a.dispatch_id AND i.customer_id=a.customer_id
JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
GROUP BY a.dispatch_id,a.destination_key,p.appointment_no,p.confirmation_no,p.appointment_start_utc
ORDER BY a.dispatch_id,a.destination_key,p.appointment_no</script>
""")
List<Batch> batches(@Param("ids") List<Long> ids);
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.dispatch.port.DispatchViews;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.util.Set;
/** 原排车只读投影和附加信息,不公开第二套履约命令。 */
public final class OwnedBusinessModels {
/** 工具容器不允许实例化。 */
private OwnedBusinessModels() {}
public static final String LIST="platform:fleet:owned-business:list";
public static final String EDIT="platform:fleet:owned-business:edit";
public static final String UPLOAD="platform:fleet:owned-business:upload";
public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED }
public enum PhotoSlot {
LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END;
/** 图片框分类不表示司机审核或真实派送签收。 */
public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; }
/** 文件类型与租户归属由本域固定,浏览器不能指定。 */
public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; }
}
public record Actor(String operatorId,String operatorName,Set<String> permissions) {}
public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size,
String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword,
Stage stage,@Size(max=64) String warehouseId) {}
public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency,
@NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {}
public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {}
public record Operations(@Valid Money money,String actualDistanceMiles,
String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {}
public record SaveOperations(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations,
@Size(max=1000) String reason) {}
public record BindPhoto(@NotBlank String recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {}
public record Gate(boolean allowed,String blockReason) {}
public record Actions(Gate edit,Gate upload) {}
public record Origin(String warehouseId,String warehouseCode,String warehouseName) {}
public record Vehicle(String vehicleId,String plate,String ownership) {}
public record Driver(String name,String phone) {}
public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {}
public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address,
List<Appointment> appointments,String actualArrivedAt) {}
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,
String sizeBytes,String uploadedAt,String savedAt) {}
public record PhotoSummary(int loadingCount,int fuelCount) {}
public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt,
String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver,
List<Destination> destinations,Integer actualPalletCount,Operations operations,String unitPrice,
String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List<String> sourceIssues) {}
public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {}
public record ListResult(List<Row> records,int page,int size,int total,Summary summary) {}
public record Cargo(String sourceKey,String customerId,String customerName,String orderNo,
String inboundCode,String palletNo,String destinationKey,String packages) {}
public record Detail(Row row,DispatchViews.Detail dispatch,List<Cargo> cargo,List<Photo> photos) {}
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,
Operations operations,List<Photo> photos) {}
public record HistoryPage(List<History> records,int page,int size,int total) {}
public record Command(String dispatchNo,String action,String idempotencyKey,String state,
String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
public record Snapshot(Operations operations,List<Photo> photos,String operatorName) {}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.fleet.ownedbusiness.model;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 仅供本包持久化,不向浏览器输出内部主键。 */
public final class OwnedBusinessRows {
/** 持久化容器不允许实例化。 */
private OwnedBusinessRows() {}
@Data public static class Ownership {
private Long dispatchId,vehicleId,vehicleRecordVersion;
private String resourceMode,vehicleOwnership,loadingCompletionHash;
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
}
@Data public static class Source extends DispatchAggregate {
private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash;
private Long ownedVehicleId,ownedVehicleRecordVersion;
private LocalDateTime ownedLoadingCompletedAt;
}
@Data public static class Record {
private Long id,dispatchId,recordVersion;
private String currency,arrivalsJson,photosJson,createdBy,updatedBy;
private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class Command {
private Long id,dispatchId,expectedVersion;
private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode;
private LocalDateTime createdAt,updatedAt;
}
@Data public static class History {
private Long id,dispatchId,recordVersion,dispatchRecordVersion;
private String eventSource,action,operatorId,reason,snapshotJson;
private LocalDateTime occurredAt;
}
@Data public static class Batch {
private Long dispatchId;
private String destinationKey,appointmentNo,confirmationNo;
private LocalDateTime startUtc;
private Long allocatedPackages;
}
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
LocalDateTime from,LocalDateTime to) {}
@Data public static class StageCount { private String stage;private Long quantity; }
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.fleet.ownedbusiness.port;
import java.time.Instant;
/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */
public interface OwnedBusinessOwnershipCapture {
/** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */
void capture(long dispatchId, Long vehicleId, String resourceMode,
String loadingCompletionHash, Instant loadingCompletedAt);
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.fleet.ownedbusiness.repository;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */
@Repository
public class OwnedBusinessRepository {
private final OwnedBusinessMapper records;
private final OwnedBusinessQueryMapper queries;
/** 注入唯一SQL提供者,Service不能直接访问Mapper。 */
public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;}
/** 原归属已捕获后不按当前车辆回算。 */
public Ownership ownership(long id){return records.ownership(id);}
/** 归属与主装车事务共同提交。 */
public void capture(Ownership row){records.capture(row);}
/** 服务端整车分页。 */
public List<Source> page(Search query){return queries.page(query);}
/** 与分页相同过滤的完整统计。 */
public List<StageCount> summary(Search query){return queries.summary(query);}
/** 读取唯一原排车身份及归属证据。 */
public Source source(String number){return queries.find(number);}
/** 页内货物批量查询。 */
public List<DispatchItem> cargo(List<Long> ids){return ids.isEmpty()?List.of():queries.cargo(ids);}
/** 页内实际预约分配批量查询。 */
public List<Batch> batches(List<Long> ids){return ids.isEmpty()?List.of():queries.batches(ids);}
/** GET只读,无记录保留虚拟0版本。 */
public Record record(long id){return records.record(id);}
/** 页内附加信息批量读取。 */
public List<Record> records(List<Long> ids){return ids.isEmpty()?List.of():records.records(ids);}
/** 首个写事务建立行,并按dispatch唯一键串行。 */
public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());}
/** 只CAS修改附加信息。 */
public int update(Record row,long expected){return records.update(row,expected);}
/** 命令先按全车请求键定位,再在服务端检查原操作者。 */
public Command command(long id,String key){return records.command(id,key);}
/** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */
public void command(Command row){records.insertCommand(row);}
/** 上传结果只完成原待定意图。 */
public int finishUpload(Command row){return records.finishUpload(row);}
/** 同一附加版本只记录一次不可变历史。 */
public void history(History row){records.insertHistory(row);}
/** 历史总数只取原dispatch。 */
public int historyCount(long id){return records.historyCount(id);}
/** 历史采用稳定版本倒序。 */
public List<History> history(long id,int offset,int size){return records.history(id,offset,size);}
/** 下载历史附件时精确定位原版本。 */
public History revision(long id,long version){return records.revision(id,version);}
}
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */
@Service
public class OwnedBusinessCommandService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessSupport codec;
/** 文件意图独立短事务,不持业务行锁跨HTTP。 */
public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;}
public record Reservation(Command command,boolean created) {}
/** 查找原命令时先核操作者,不披露别人的原载荷。 */
public Command existing(long dispatchId,String key,Actor actor) {
var row=repository.command(dispatchId,key);
if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号");
return row;
}
/** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */
public void same(Command row,String action,Long version,Object request) {
if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version)
|| !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同");
}
/** 完整命令实体只用于本页,不保存浏览器凭据。 */
public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) {
var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id);
row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version);
row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row;
}
/** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */
@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
public Reservation reserveUpload(long id,Actor actor,String key,Object request) {
var previous=existing(id,key,actor);
if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);}
var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING");
try{repository.command(row);return new Reservation(row,true);}
catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);}
}
/** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */
@Transactional(propagation=Propagation.REQUIRES_NEW)
public void finishUpload(Command command,String status,Object result,String errorCode) {
command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode);
command.setUpdatedAt(codec.utc(Instant.now()));
if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求");
}
/** 写命令与附加更新、历史在同一外部事务提交。 */
public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) {
var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command);
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
import java.util.Objects;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */
@Service
public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture {
private final OwnedBusinessRepository repository;
private final VehicleRepository vehicles;
/** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */
public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;}
/** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) {
if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null
|| !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整");
if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法");
if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致");
var existing=repository.ownership(dispatchId);
if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;}
var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode);
if("MASTER".equals(resourceMode)) {
var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在"));
if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效");
if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确");
row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion());
} else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);}
row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC));
row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash);
try{repository.capture(row);}catch(DuplicateKeyException duplicate){
var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate;
same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);
if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化");
}
}
/** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */
private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) {
if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode)
|| !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC)))
throw conflict("原装车车辆归属证据不同");
}
}
@@ -0,0 +1,95 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */
@Service
public class OwnedBusinessPhotoService {
private final OwnedBusinessQueryService queries;
private final OwnedBusinessRecordService records;
private final OwnedBusinessRepository repository;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final OwnedBusinessFileGateway files;
/** HTTP远程请求不在附加行写事务内持锁等待。 */
public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,
OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,OwnedBusinessFileGateway files) {
this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files;
}
public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {}
public record Download(Photo photo,byte[] content) {}
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number);
if(slot==null || multipart==null)throw bad("请选择照片及分类");
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes);
HttpOwnedBusinessFileGateway.validate(payload);
var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes));
var reservation=commands.reserveUpload(source.getId(),actor,key,identity);
if(!reservation.created()) {
if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传");
return codec.read(reservation.command().getResultJson(),Photo.class);
}
try {
var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload));
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
} catch(RuntimeException failure) {
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");}
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
}
}
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
if(fileId<=0)throw bad("文件编号无效");
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null) {
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
return codec.read(previous.getResultJson(),Detail.class);
}
return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId)));
}
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
codec.require(actor,LIST);var source=queries.requireOwned(number);List<Photo> bound;
if(revision==null)bound=projection.photos(repository.record(source.getId()));
else {
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
}
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing);
return new Download(photo,files.content(source.getId(),slot,fileId));
}
/** 恢复先校验原操作者,不披露他人载荷。 */
@Transactional(readOnly=true)
public Command command(Actor actor,String number,String key) {
codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor);
if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
}
/** 上传回执不填写保存时间,避免假称已经关联成功。 */
private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) {
return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(),
String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null);
}
}
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import org.springframework.stereotype.Component;
import java.math.*;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */
@Component
public class OwnedBusinessProjection {
private final OwnedBusinessSupport codec;
/** 所有数值与日期转换沿统一显式规则。 */
public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;}
/** 未登记为null,不以默认油耗、金额或到达填充。 */
public Operations operations(Record record) {
if(record==null)return new Operations(null,null,null,List.of());
var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString());
return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()),
codec.list(record.getArrivalsJson(),Arrival.class));
}
/** 照片已绑定才出现在当前记录,无文件不生成占位图。 */
public List<Photo> photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);}
/** 使用原货物确定目的地key,保持与排车及到达登记一致。 */
public String destinationKey(DispatchItem item) {
String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),
item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key;
}
/** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */
public int palletCount(Source source) {
var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class);
if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失");
var ids=new HashSet<Long>();
for(var pallet:confirmed.pallets()) {
if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失");
ids.add(pallet.warehousePalletId());
}
return ids.size();
}
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
public Row row(Source source,Record record,List<DispatchItem> cargo,List<Batch> batches,Actor actor) {
var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>();
for(var item:cargo) {
String key=destinationKey(item);
destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(),
item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment(
b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k)));
}
String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString();
var driver=resources.driver();
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
}
/** 附加权限独立于原排车写动作。 */
private Gate gate(Actor actor,String permission) {
boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission);
return new Gate(allowed,allowed?null:"没有该业务操作权限");
}
/** 数据库十进制保持显式单位,不以空值替换0。 */
private String decimal(BigDecimal value){return value==null?null:value.toPlainString();}
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
@Service
public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessProjection projection;
private final OwnedBusinessSupport codec;
private final DispatchAggregateService dispatch;
/** 主单详情沿既有真实提供者,列表使用本域批量投影。 */
public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection,
OwnedBusinessSupport codec,DispatchAggregateService dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;}
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public ListResult list(Actor actor,Query query) {
codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失");
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to));
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
for(var c:counts) {
int n=Math.toIntExact(c.getQuantity());
switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");}
}
int total=Math.addExact(Math.addExact(after,delivering),delivered);
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()),
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList();
return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered));
}
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
public Detail detailForWrite(Actor actor,Source source) {
eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor);
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
projection.photos(repository.record(source.getId())));
}
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
public Source requireOwned(String number) {
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw missing();eligible(source);return source;
}
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing();
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId())
|| !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
}
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize);
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(),
h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(),
snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos());
}).toList(),page,size,repository.historyCount(source.getId()));
}
/** 页码初始1,缺失与非法值不能混同。 */
private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;}
/** 列表和历史均限制单页读取量。 */
private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;}
/** 空白可选条件归一化,无限长输入明确拒绝。 */
private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();}
}
@@ -0,0 +1,110 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */
@Service
public class OwnedBusinessRecordService {
private final OwnedBusinessRepository repository;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessProjection projection;
private final OwnedBusinessCommandService commands;
private final OwnedBusinessSupport codec;
/** 业务规则经Repository读写,命令与历史一起提交。 */
public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries,
OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) {
this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec;
}
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail save(Actor actor,String number,SaveOperations request) {
codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
var ops=normalize(source,request.operations());var row=lock(source,actor);
previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
requireVersion(row,version);
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
row.setArrivalsJson(codec.write(ops.arrivals()));
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason());
}
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number);
Object identity=new PhotoBinding(slot,request);
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框");
var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor);
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
requireVersion(row,version);
var photos=new EnumMap<PhotoSlot,Photo>(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p);
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,"保存"+slot.name()+"照片");
}
/** 照片框也参与摘要,不能换URL框位复用原键。 */
public record PhotoBinding(PhotoSlot slot,BindPhoto request) {}
/** 先校验所有目的地及数值,再进入附加行写事务。 */
private Operations normalize(Source source,Operations ops) {
Money money=null;
if(ops.money()!=null) {
if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD");
String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记");
money=new Money(ops.money().currency(),amount);
}
if(ops.arrivals()==null)throw bad("目的地到达登记不完整");
var destinations=new HashSet<String>();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item));
var seen=new HashSet<String>();var arrivals=new ArrayList<Arrival>();
for(var arrival:ops.arrivals()) {
if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车");
var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString()));
}
arrivals.sort(Comparator.comparing(Arrival::destinationKey));
return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3),
codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals));
}
/** 首次写建立0版本附加行,仅在成功事务中最终提交。 */
private Record lock(Source source,Actor actor) {
var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());seed.setCreatedBy(actor.operatorId());
seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt());
var row=repository.lock(seed);if(row==null)throw conflict("附加记录初始化失败");return row;
}
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) {
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result;
}
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
private Detail result(OwnedBusinessRows.Command command) {
if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复");
return codec.read(command.getResultJson(),Detail.class);
}
/** null是未登记,不以0代替。 */
private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);}
}
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.fleet.ownedbusiness.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JavaType;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** 只处理本页格式、身份和JSON,不读写主流程。 */
@Component
public class OwnedBusinessSupport {
private final ObjectMapper json;
/** 复用服务统一Jackson,不建立第二套日期或版本协议。 */
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
public Actor actor(AuthenticatedUser user) {
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务");
String operator=user.userId()==null?user.username():user.userId().toString();
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions()));
}
/** 写权限独立校验,直接Service调用也不能绕过。 */
public void require(Actor actor,String permission) {
if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限");
}
/** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */
public <T>T read(String value,Class<T> type) {
if(value==null || value.isBlank())throw conflict("缺少可靠业务事实");
try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");}
}
/** 集合记录与声明的元素类型共同校验。 */
public <T>List<T> list(String value,Class<T> type) {
JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type);
try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");}
}
/** 原请求和原结果完整持久化,序列化失败必须中止命令。 */
public String write(Object value) {
try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);}
}
/** 请求摘要不包含版本漂移后的当前数据。 */
public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));}
/** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */
public String digest(byte[] bytes) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);}
}
/** 版本0合法,但缺失、负数和超长版本不得降级为0。 */
public long version(String value) {
try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);}
catch(Exception error){throw bad("请提交有效的原记录版本");}
}
/** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */
public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");}
/** 只接受带时区的真实时间,统一存UTC。 */
public Instant instant(String value) {
try{return value==null?null:OffsetDateTime.parse(value).toInstant();}
catch(Exception error){throw bad("时间须包含明确时区");}
}
/** 关系库时间只表示UTC,不取运行机默认时区。 */
public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
/** 展示原真实时间,不根据签收或预计时间填充。 */
public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
/** 规范十进制输入,空值与明确零分别保存。 */
public String decimal(String value,int integers,int scale) {
if(value==null)return null;
if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制");
return new BigDecimal(value).setScale(scale).toPlainString();
}
/** 用户输入错误保持项目业务错误约定。 */
public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
/** 业务并发或来源事实不符不能返回模拟成功。 */
public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
/** 不存在只使用业务提示,不泄露内部主键。 */
public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");}
}