This commit is contained in:
2026-11-03 16:51:30 +08:00
parent 1506576c67
commit 5d6b2812cf
11 changed files with 354 additions and 0 deletions
@@ -30,6 +30,10 @@
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
### 客户原单运输照片读取边界(2026-11-03)
客户订单域通过 `trucking.port.MerchantTransportPhotos` 声明只读照片查询和内容接口,散板及卡车客户 Controller 复用已有原单列表权限与认证客户范围。仅 `dispatch.transport.MerchantTransportPhotoService` 实现此端口,按同客户商业原单的有效排车关联读取散板当前货物/审核轮次照片或本土卡车取货/完成凭证;下载再次核对当前绑定。结构门禁只登记此实现类到端口的精确依赖,不开放客户订单 Controller 到排车具体 Service、Repository 或 Mapper,也不新增包循环。
### 装车资料触发派送边界(2026-10-21)
`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 客户照片的真实承运范围,只关联同客户商业原单,不按展示编号猜来源。 */
@Mapper
public interface MerchantTransportPhotoMapper {
/** 申报散板或实收散板均沿原单、入仓计划和排车明细精确关联。 */
@Select("""
SELECT DISTINCT d.dispatch_no FROM ll_local_looseboard_order o
JOIN ll_inbound_plan p ON p.source_order_id=o.lastleg_order_id AND p.customer_id=o.customer_id
JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id
JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=o.customer_id
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'
WHERE o.customer_id=#{customer} AND o.order_no=#{orderNo} ORDER BY d.dispatch_no
""")
List<String> looseboardDispatches(@Param("customer")String customer,@Param("orderNo")String orderNo);
/** 本土卡车只读取本客户原单当前有效排车,已取消安排不暴露。 */
@Select("""
SELECT t.dispatch_no FROM ll_dispatch_truck_task t
JOIN ll_dispatch_order d ON d.dispatch_no=t.dispatch_no AND d.status='ACTIVE' AND d.task_type='LOCAL_TRUCK'
WHERE t.customer_id=#{customer} AND t.order_no=#{orderNo} AND t.active_order IS NOT NULL
ORDER BY d.id
""")
List<String> truckDispatches(@Param("customer")String customer,@Param("orderNo")String orderNo);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.MerchantTransportPhotoMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 客户照片仅查询已核准客户与原单的有效承运关联。 */
@Repository
public class MerchantTransportPhotoRepository {
private final MerchantTransportPhotoMapper mapper;
/** 注入本域查询入口。 */
public MerchantTransportPhotoRepository(MerchantTransportPhotoMapper mapper){this.mapper=mapper;}
/** 返回原散板单实际关联的排车身份。 */
public List<String> looseboardDispatches(String customer,String number){return mapper.looseboardDispatches(customer,number);}
/** 返回原卡车单有效运输任务。 */
public List<String> truckDispatches(String customer,String number){return mapper.truckDispatches(customer,number);}
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.trucking.port.MerchantTransportPhotos;
import com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
/** 客户读取各自原单照片;复用正式照片事实与文件归属,不授权访问整车资料。 */
@Service
public class MerchantTransportPhotoService implements MerchantTransportPhotos {
private record Bound(Photo photo,Phase phase,Long dispatchId,String truckDispatch) {}
private final MerchantTransportPhotoRepository links;
private final DispatchAggregateRepository roots;
private final DispatchLooseboardFacts looseboard;
private final DispatchEvidenceGateway files;
private final TransportTaskService trucks;
/** 照片查询与下载沿正式承运来源及文件网关。 */
public MerchantTransportPhotoService(MerchantTransportPhotoRepository links,DispatchAggregateRepository roots,
DispatchLooseboardFacts looseboard,DispatchEvidenceGateway files,TransportTaskService trucks){
this.links=links;this.roots=roots;this.looseboard=looseboard;this.files=files;this.trucks=trucks;
}
/** 只返回当前货物与审核轮次仍有效、且已保存的本票照片。 */
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail photos(Business business,String customer,String number){
var bound=bound(business,customer,number);
return new Detail(bound.stream().filter(p->p.phase()==Phase.PICKUP).map(Bound::photo).distinct().toList(),
bound.stream().filter(p->p.phase()==Phase.ARRIVAL).map(Bound::photo).distinct().toList());
}
/** 下载重新限定原单节点;被删除、过期轮次、其他客户与未绑定上传均不能读取。 */
@Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Download content(Business business,String customer,String number,Phase phase,long fileId){
var photo=bound(business,customer,number).stream().filter(p->p.phase()==phase && p.photo().fileId().equals(Long.toString(fileId)))
.findFirst().orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"当前订单没有该照片"));
byte[] bytes=photo.truckDispatch()!=null?trucks.evidence(photo.truckDispatch(),fileId):files.download(photo.dispatchId(),
phase==Phase.PICKUP?DispatchEvidenceGateway.Kind.LOOSEBOARD_PICKUP:DispatchEvidenceGateway.Kind.LOOSEBOARD_ARRIVAL,fileId);
return new Download(photo.photo(),bytes);
}
/** 混排只筛选本票照片,卡车复用真实取货和完成命令中的凭证分类。 */
private List<Bound> bound(Business business,String customer,String number){
var result=new ArrayList<Bound>();
if(business==Business.LOOSEBOARD){
for(String dispatch:links.looseboardDispatches(customer,number)){
var row=roots.find(dispatch);if(row==null || !"ACTIVE".equals(row.getStatus()))continue;
long id=row.getId();var cargo=looseboard.cargo(id);
for(var evidence:looseboard.current(row)){
if(!customer.equals(evidence.customerId()) || !number.equals(evidence.orderNo()))continue;
if(cargo.stream().noneMatch(c->DispatchLooseboardFacts.matches(evidence,c)))continue;
for(var file:evidence.files())result.add(new Bound(new Photo(file.fileId().toString(),file.fileName(),file.contentType(),file.sizeBytes()),
Phase.valueOf(evidence.phase().name()),id,null));
}
}
}else for(String dispatch:links.truckDispatches(customer,number)){
var task=trucks.detail(dispatch);
if(!customer.equals(task.customerId()) || !number.equals(task.orderNo()))throw new IllegalStateException("卡车照片归属不匹配");
for(var file:task.departureEvidence())result.add(new Bound(new Photo(file.fileId(),file.fileName(),file.contentType(),file.sizeBytes()),Phase.PICKUP,null,dispatch));
for(var file:task.completionEvidence())result.add(new Bound(new Photo(file.fileId(),file.fileName(),file.contentType(),file.sizeBytes()),Phase.ARRIVAL,null,dispatch));
}
return List.copyOf(result);
}
}
@@ -1,6 +1,10 @@
package com.baiyue.lastleg.looseboard.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.trucking.port.MerchantTransportPhotos;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
@@ -54,6 +58,27 @@ public class MerchantLocalLooseboardController {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
}
@Resource private MerchantTransportPhotos transportPhotos;
/** 客户只读自己原单的取货和派送照片,不接受请求指定客户。 */
@GetMapping("/{orderNo}/transport-photos")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<MerchantTransportPhotos.Detail> photos(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo) {
String customer=customer(user);service.detail(customer,orderNo);
return ApiResponse.success(transportPhotos.photos(MerchantTransportPhotos.Business.LOOSEBOARD,customer,orderNo));
}
/** 文件内容由同一客户、原单和已保存节点引用重新授权,不提供公共文件地址。 */
@GetMapping("/{orderNo}/transport-photos/{phase}/{fileId}/content")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ResponseEntity<byte[]> photoContent(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,
@PathVariable MerchantTransportPhotos.Phase phase,@PathVariable long fileId) {
String customer=customer(user);service.detail(customer,orderNo);
var result=transportPhotos.content(MerchantTransportPhotos.Business.LOOSEBOARD,customer,orderNo,phase,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.bytes());
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
@@ -1,6 +1,10 @@
package com.baiyue.lastleg.trucking.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.trucking.port.MerchantTransportPhotos;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
@@ -65,6 +69,27 @@ public class MerchantUsTruckOrderController {
return ApiResponse.success(service.changeStatus(customer(user), orderNo, operator(user), request));
}
@Resource private MerchantTransportPhotos transportPhotos;
/** 客户只读自己原单的取货和派送照片,不接受请求指定客户。 */
@GetMapping("/{orderNo}/transport-photos")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')")
public ApiResponse<MerchantTransportPhotos.Detail> photos(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo) {
String customer=customer(user);service.detail(customer,orderNo);
return ApiResponse.success(transportPhotos.photos(MerchantTransportPhotos.Business.TRUCK,customer,orderNo));
}
/** 文件内容由同一客户、原单和已保存节点引用重新授权,不提供公共文件地址。 */
@GetMapping("/{orderNo}/transport-photos/{phase}/{fileId}/content")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')")
public ResponseEntity<byte[]> photoContent(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,
@PathVariable MerchantTransportPhotos.Phase phase,@PathVariable long fileId) {
String customer=customer(user);service.detail(customer,orderNo);
var result=transportPhotos.content(MerchantTransportPhotos.Business.TRUCK,customer,orderNo,phase,fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(result.bytes());
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.trucking.port;
import java.util.List;
/** 客户只读运输照片,不公开排车、库存或其他客户的作业资料。 */
public interface MerchantTransportPhotos {
enum Business { LOOSEBOARD, TRUCK }
enum Phase { PICKUP, ARRIVAL }
record Photo(String fileId,String fileName,String contentType,long sizeBytes) {}
record Detail(List<Photo> pickupPhotos,List<Photo> arrivalPhotos) {}
record Download(Photo photo,byte[] bytes) {}
/** 仅按已认证客户与商业原单读取当前有效凭证。 */
Detail photos(Business business,String customer,String orderNo);
/** 必须重新核对本客户、本票与节点当前绑定,不能用上传回执直接下载。 */
Download content(Business business,String customer,String orderNo,Phase phase,long fileId);
}
@@ -0,0 +1,62 @@
openapi: 3.0.3
info:
title: 客户本土散板及卡车运输照片
version: 1.0.0
description: 只读当前客户商业原单的已保存取货和派送凭证。散板按当前货物与审核轮次过滤,混排不披露其他客户或其他订单照片;卡车沿有效运输关联读取取货和完成凭证。上传但未绑定、已删除和已取消安排的照片不可读取。
paths:
/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}/transport-photos:
get:
summary: 本客户散板原单取货和派送照片
description: 使用 merchant:cross-border:pallet-shipping:list 权限及 Token 客户范围。
parameters: [{ $ref: '#/components/parameters/OrderNo' }]
responses:
'200': { $ref: '#/components/responses/Photos' }
/api/merchant/v1/last-leg/us-truck-orders/{orderNo}/transport-photos:
get:
summary: 本客户卡车原单取货和派送凭证
description: 使用 merchant:cross-border:domestic-trucking:list 权限及 Token 客户范围。
parameters: [{ $ref: '#/components/parameters/OrderNo' }]
responses:
'200': { $ref: '#/components/responses/Photos' }
/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}/transport-photos/{phase}/{fileId}/content:
get:
summary: 读取本客户散板已保存节点照片
description: 同散板列表权限;按原单、节点和文件引用重新授权,返回 no-store 及 nosniff。
parameters: [{ $ref: '#/components/parameters/OrderNo' }, { $ref: '#/components/parameters/Phase' }, { $ref: '#/components/parameters/FileId' }]
responses:
'200': { description: 文件二进制或统一业务失败响应, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
/api/merchant/v1/last-leg/us-truck-orders/{orderNo}/transport-photos/{phase}/{fileId}/content:
get:
summary: 读取本客户卡车已保存节点凭证
description: 同卡车列表权限;按原单、节点和文件引用重新授权,返回 no-store 及 nosniff。
parameters: [{ $ref: '#/components/parameters/OrderNo' }, { $ref: '#/components/parameters/Phase' }, { $ref: '#/components/parameters/FileId' }]
responses:
'200': { description: 文件二进制或统一业务失败响应, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
components:
parameters:
OrderNo: { name: orderNo, in: path, required: true, schema: { type: string } }
Phase: { name: phase, in: path, required: true, schema: { type: string, enum: [PICKUP, ARRIVAL] } }
FileId: { name: fileId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses:
Photos:
description: HTTP 200统一业务响应;成功 code 为字符串200,data包含两类照片,空集合不生成虚构凭证。
content:
application/json:
schema:
type: object
properties:
code: { type: string }
message: { type: string }
data:
type: object
properties:
pickupPhotos: { type: array, items: { $ref: '#/components/schemas/Photo' } }
arrivalPhotos: { type: array, items: { $ref: '#/components/schemas/Photo' } }
schemas:
Photo:
type: object
properties:
fileId: { type: string }
fileName: { type: string }
contentType: { type: string }
sizeBytes: { type: integer, format: int64 }
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.dispatch.repository.MerchantTransportPhotoRepository;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class MerchantTransportPhotoMapperTest {
/** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */
@Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(data);
db.execute("CREATE TABLE ll_local_looseboard_order(customer_id VARCHAR(20),order_no VARCHAR(30),lastleg_order_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(20),source_order_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR(20))");
db.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR(30),status VARCHAR(20),task_type VARCHAR(20))");
db.execute("CREATE TABLE ll_dispatch_truck_task(dispatch_no VARCHAR(30),customer_id VARCHAR(20),order_no VARCHAR(30),active_order VARCHAR(50))");
db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)");
db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)");
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)");
db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')");
var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class));
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1");
assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty();
assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3");
assertThat(repository.truckDispatches("C2","TR1")).isEmpty();
db.update("UPDATE ll_dispatch_truck_task SET active_order=NULL WHERE dispatch_no='PC3'");
assertThat(repository.truckDispatches("C1","TR1")).isEmpty();
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.trucking.port.MerchantTransportPhotos.*;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.transport.*;
import com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
class MerchantTransportPhotoServiceTest {
/** 混装只回显本客户本票当前照片,阶段和文件ID不能绕过授权;删除后立即不可下载。 */
@Test void looseboardPhotosStayWithinCustomerOrderAndCurrentBinding(){
var f=new Fixture();var row=new DispatchAggregate();row.setId(1L);row.setStatus("ACTIVE");
when(f.links.looseboardDispatches("C1","LB1")).thenReturn(List.of("PC1"));when(f.roots.find("PC1")).thenReturn(row);
var item=new DispatchItem();item.setId(10L);item.setCustomerId("C1");
when(f.facts.cargo(1)).thenReturn(List.of(new DispatchLooseboardFacts.Cargo(item,"LB1")));
when(f.facts.current(row)).thenReturn(List.of(evidence("C1","LB1",10,DispatchLooseboardFacts.Phase.PICKUP,71),
evidence("C1","LB1",10,DispatchLooseboardFacts.Phase.ARRIVAL,72),evidence("C2","LB2",20,DispatchLooseboardFacts.Phase.PICKUP,73),
evidence("C1","LB1",99,DispatchLooseboardFacts.Phase.PICKUP,74)));
var photos=f.service.photos(Business.LOOSEBOARD,"C1","LB1");
assertThat(photos.pickupPhotos()).extracting(Photo::fileId).containsExactly("71");
assertThat(photos.arrivalPhotos()).extracting(Photo::fileId).containsExactly("72");
assertThatThrownBy(()->f.service.content(Business.LOOSEBOARD,"C1","LB1",Phase.PICKUP,73)).hasMessageContaining("没有该照片");
assertThatThrownBy(()->f.service.content(Business.LOOSEBOARD,"C1","LB1",Phase.ARRIVAL,71)).hasMessageContaining("没有该照片");
verifyNoInteractions(f.files);
when(f.files.download(1,DispatchEvidenceGateway.Kind.LOOSEBOARD_PICKUP,71)).thenReturn(new byte[]{1});
assertThat(f.service.content(Business.LOOSEBOARD,"C1","LB1",Phase.PICKUP,71).bytes()).containsExactly(1);
when(f.facts.current(row)).thenReturn(List.of());
assertThatThrownBy(()->f.service.content(Business.LOOSEBOARD,"C1","LB1",Phase.PICKUP,71)).hasMessageContaining("没有该照片");
when(f.facts.current(row)).thenReturn(List.of(evidence("C1","LB1",10,DispatchLooseboardFacts.Phase.PICKUP,71)));
row.setStatus("CANCELLED");assertThat(f.service.photos(Business.LOOSEBOARD,"C1","LB1").pickupPhotos()).isEmpty();
}
/** 卡车保持取货与完成分类,读取已绑定凭证且不披露司机证件;关联客户异常直接拒绝。 */
@Test void truckPhotosUseOnlyLinkedTransportEvidence(){
var f=new Fixture();when(f.links.truckDispatches("C1","TR1")).thenReturn(List.of("PC2"));
when(f.trucks.detail("PC2")).thenReturn(truck("C1","TR1"));
var photos=f.service.photos(Business.TRUCK,"C1","TR1");
assertThat(photos.pickupPhotos()).extracting(Photo::fileId).containsExactly("81");
assertThat(photos.arrivalPhotos()).extracting(Photo::fileId).containsExactly("82");
when(f.trucks.evidence("PC2",82)).thenReturn(new byte[]{2});
assertThat(f.service.content(Business.TRUCK,"C1","TR1",Phase.ARRIVAL,82).bytes()).containsExactly(2);
assertThatThrownBy(()->f.service.content(Business.TRUCK,"C1","TR1",Phase.PICKUP,82)).hasMessageContaining("没有该照片");
when(f.trucks.detail("PC2")).thenReturn(truck("C2","TR1"));
assertThatThrownBy(()->f.service.photos(Business.TRUCK,"C1","TR1")).hasMessageContaining("归属不匹配");
}
/** 测试凭证保留真实节点身份,不将上传回执当作保存事实。 */
private static DispatchLooseboardFacts.Evidence evidence(String customer,String order,long item,DispatchLooseboardFacts.Phase phase,long file){
return new DispatchLooseboardFacts.Evidence(Long.toString(item),customer,order,phase,"hash",1,
List.of(new DispatchEvidenceGateway.File(file,"photo.png","image/png",5)),Instant.now(),"operator","key","hash");
}
/** 卡车资料夹具仅包含取货与完成附件。 */
private static TransportTaskModels.Detail truck(String customer,String order){return new TransportTaskModels.Detail("PC2","LOCAL_TRUCK",null,customer,order,List.of(),
List.of(new EvidenceFile("81","pickup.png","image/png",5)),List.of(new EvidenceFile("82","arrival.png","image/png",5)),1L,null,null);}
static class Fixture {
final MerchantTransportPhotoRepository links=mock(MerchantTransportPhotoRepository.class);
final DispatchAggregateRepository roots=mock(DispatchAggregateRepository.class);
final DispatchLooseboardFacts facts=mock(DispatchLooseboardFacts.class);
final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class);
final TransportTaskService trucks=mock(TransportTaskService.class);
final MerchantTransportPhotoService service=new MerchantTransportPhotoService(links,roots,facts,files,trucks);
}
}
@@ -56,6 +56,11 @@
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity"
],
"reviewedClassDependencies": [
{
"source": "com.baiyue.lastleg.dispatch.transport.MerchantTransportPhotoService",
"target": "com.baiyue.lastleg.trucking.port.MerchantTransportPhotos",
"reason": "客户订单域声明取货和派送照片只读端口,排车运输域按客户与商业原单精确关联实现;不开放客户对整车、司机证件或其他客户照片的访问,不开放反向具体服务依赖。"
},
{
"source": "com.baiyue.lastleg.looseboard.service.LocalLooseboardService",
"target": "com.baiyue.lastleg.dispatch.port.LooseboardTransportPort",