From 5cd108b39ac0778f0b099f1ef856aa5a24de678a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 11:43:10 +0800 Subject: [PATCH] chore(lastleg): instrument appointment latency from restored 662dead baseline --- .../AppointmentCargoReadRepository.java | 16 - .../AppointmentCargoVerificationService.java | 18 -- .../AppointmentSourceReadRepository.java | 26 -- .../AppointmentSourceService.java | 85 ------ .../AppointmentWorkFactAdapter.java | 26 +- .../AppointmentWorkFactGateway.java | 5 +- .../AppointmentWorkQueryService.java | 38 ++- .../appointmentwork/AppointmentWorkStore.java | 19 +- .../AppointmentWorkSyncService.java | 29 +- ...ppointmentWorkDataSourcePostProcessor.java | 35 +++ .../AppointmentWorkDiagnosticFilter.java | 39 +++ .../AppointmentWorkDiagnostics.java | 128 ++++++++ ...pointmentWorkDiagnosticsConfiguration.java | 47 +++ .../AppointmentWorkMyBatisDiagnostics.java | 51 ++++ .../InboundFbaAppointmentQueryMapper.java | 22 -- .../service/InboundFbaAppointmentService.java | 37 --- .../AppointmentRequirementReadRepository.java | 28 -- .../work/AppointmentWorkDeliveryProvider.java | 16 +- .../AppointmentWorkRequirementProvider.java | 102 ++----- ...AppointmentCargoVerificationBatchTest.java | 16 - .../AppointmentPlanningAuthorizationTest.java | 4 - .../AppointmentSourceServiceTest.java | 50 --- .../AppointmentWorkFactAdapterTest.java | 25 +- .../AppointmentWorkPersistenceTest.java | 2 +- .../AppointmentWorkQueryServiceTest.java | 29 ++ .../AppointmentWorkSyncServiceTest.java | 61 ++-- .../appointmentwork/WorkTestFixture.java | 2 +- .../AppointmentWorkDiagnosticFilterTest.java | 34 +++ ...mentWorkDiagnosticsInfrastructureTest.java | 287 ++++++++++++++++++ .../AppointmentWorkDiagnosticsTest.java | 84 +++++ ...AppointmentWorkMyBatisDiagnosticsTest.java | 33 ++ .../FbaAppointmentPersistenceTest.java | 6 - ...ointmentRequirementReadRepositoryTest.java | 244 --------------- .../AppointmentWorkDeliveryProviderTest.java | 12 +- ...ppointmentWorkRequirementProviderTest.java | 75 ++--- 35 files changed, 951 insertions(+), 780 deletions(-) delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java deleted file mode 100644 index ebec3cdd6..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.lastleg.appointmentplanning; - -import org.springframework.stereotype.Repository; -import java.util.List; - -/** 单次生成只批量读取核实与分配原料,业务解析仍按实际访问顺序。 */ -@Repository -public class AppointmentCargoReadRepository { - private final AppointmentCargoVerificationMapper mapper; - /** 复用本域既有批量查询。 */ - public AppointmentCargoReadRepository(AppointmentCargoVerificationMapper mapper) { this.mapper=mapper; } - /** 取得每客户来源的最新核实记录,暂不解析货件 JSON。 */ - public List latest(List refs) { return refs.isEmpty()?List.of():mapper.latestBatch(refs); } - /** 分配数量仅取活动明细,未知数量保持单独计数。 */ - public List allocations(List refs) { return refs.isEmpty()?List.of():mapper.allocatedBatch(refs); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java index a05e33b7c..e0bc99167 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -21,7 +21,6 @@ public class AppointmentCargoVerificationService { private final AppointmentSourceService sources; private final AppointmentCargoVerificationMapper mapper; private final ObjectMapper json; - @jakarta.annotation.Resource private AppointmentCargoReadRepository readRepository; public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, ObjectMapper json) { @@ -51,23 +50,6 @@ public class AppointmentCargoVerificationService { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { public Context requireContext() { if(error!=null) throw error; return context; } } - /** 只预取本次已授权来源的原料;核实 JSON 与数量判断按原 anyMatch 实际访问顺序执行。 */ - public java.util.function.Function prepareInspection(List resolved) { - var refs=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); - var latest=new HashMap,AppointmentCargoVerification>(); - var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); - for(int start=0;startlatest.put(List.of(r.getCustomerId(),r.getSourceKey()),r)); - readRepository.allocations(batch).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems()))); - } - var allowed=new HashSet<>(resolved); - return source->{ - if(!allowed.contains(source)) throw new IllegalArgumentException("货物核实来源不属于本次读取范围"); - var key=List.of(source.customerId(),source.sourceKey()); - return inspect(source,latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))); - }; - } /** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ public List inspectBatch(List refs) { var result=new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java deleted file mode 100644 index 7ff060a6e..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.lastleg.appointmentplanning; - -import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; -import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; -import org.springframework.stereotype.Repository; -import java.util.List; - -/** 同次需求生成的来源原料读取,不保存跨调用状态。 */ -@Repository -public class AppointmentSourceReadRepository { - private final LastlegOrderMapper orders; - private final ContainerShippingGateMapper shipping; - private final AppointmentCoverageMapper destinations; - /** 仅组合本域既有批量持久查询。 */ - public AppointmentSourceReadRepository(LastlegOrderMapper orders,ContainerShippingGateMapper shipping,AppointmentCoverageMapper destinations) { - this.orders=orders;this.shipping=shipping;this.destinations=destinations; - } - /** 读取所选订单,客户归属继续由来源服务逐需求核验。 */ - public List orders(List ids) { return ids.isEmpty()?List.of():orders.selectBatchIds(ids); } - /** 读取实际船节点门禁,不从计划时间推断。 */ - public List gates(List ids) { return ids.isEmpty()?List.of():shipping.selectBatchIds(ids); } - /** 转运目的地仍取原业务权威关系。 */ - public List destinations(List refs) { return refs.isEmpty()?List.of():destinations.sources(refs); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index 91e1e3341..d2957eb37 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -26,7 +26,6 @@ public class AppointmentSourceService { private final AppointmentWarehouseClient warehouses; private final String token; @jakarta.annotation.Resource private AppointmentCoverageMapper destinationFacts; - @jakarta.annotation.Resource private AppointmentSourceReadRepository readRepository; public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping, AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses, @Value("${baiyue.security.internal-service-token:}") String token) { @@ -73,96 +72,12 @@ public class AppointmentSourceService { public List resolveBatch(List requested) { return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList(); } - /** 同页只合并原料查询;来源规范化、重复与权限检查仍按每份需求独立执行。 */ - public List resolveOrderGroups(List> groups) { - var canonical=new java.util.ArrayList>(); - var errors=new java.util.ArrayList(); - var original=new LinkedHashSet(); - for(var group:groups) { - RuntimeException error=null; - if(group==null || group.isEmpty() || group.size()>100) error=new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Firstleg需求缺少完整客户货源关系"); - else if(group.stream().anyMatch(r->r==null || blank(r.customerId()) || r.orderId()==null || r.orderId()<=0)) error=bad("请选择完整的真实货源"); - errors.add(error);if(error==null) original.addAll(group); - } - var aliases=new java.util.HashMap(); - var originals=new java.util.ArrayList<>(original); - for(int start=0;start(); - var orderIds=new LinkedHashSet(); - for(int i=0;i{ - var alias=aliases.get(ref); - return new AppointmentCoverageService.SourceRef(ref.customerId(),alias==null?"ORDER:"+ref.orderId():"BOOKING:"+alias.bookingNo(),null); - }).toList(); - // 保留原 canonical 的数量校验,不能以全页去重掩盖单需求重复。 - if(refs.size()!=groups.get(i).size()) errors.set(i,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源键必须明确为ORDER或BOOKING")); - refs=refs.stream().distinct().toList();canonical.add(refs); - for(var ref:refs) if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey())); - else bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))); - } - var bookingMap=new java.util.HashMap(); - var bookings=new java.util.ArrayList<>(bookingRefs); - for(int start=0;start(); - var gates=new java.util.HashMap(); - var destinations=new java.util.HashMap(); - var ids=new java.util.ArrayList<>(orderIds); - for(int start=0;start{if(orderMap.put(row.getId(),row)!=null) throw new IllegalStateException("原订单来源重复");}); - } - var containers=orderMap.values().stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); - for(int start=0;start{ - if(gates.put(g.getContainerBookingId(),g.getNodeSequence()!=null && g.getNodeSequence()>=40)!=null) throw new IllegalStateException("原船节点门禁重复"); - }); - var transfers=orderMap.values().stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList(); - for(int start=0;start{ - if(destinations.put(d.orderId(),d)!=null) throw new IllegalStateException("原目的地来源重复"); - }); - var result=new java.util.ArrayList();RuntimeException infrastructure=null; - for(int i=0;i(); - if(error==null) error=infrastructure; - if(error==null) try { - var keys=new java.util.HashSet();var materialized=new java.util.HashSet(); - for(var ref:refs) { - if(blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源"); - if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择"); - } - var directory=catalog(); - for(var ref:refs) { - var source=resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,directory,destinations); - if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); - resolved.add(source); - } - } catch(RuntimeException failed) { - error=failed; - // 基础设施失败不重试、不变为空数据,延后到原需求访问位置抛出。 - if(!(failed instanceof ResponseStatusException status) || status.getStatusCode().value()>=500) infrastructure=failed; - } - result.add(new OrderGroup(refs,List.copyOf(resolved),error)); - } - return List.copyOf(result); - } /** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */ private List catalog() { if(directory==null) directory=AppointmentSourceService.this.catalog(); return directory; } } - public record OrderGroup(List references,List sources,RuntimeException error) { - /** 业务前置错误仍在原需求解析位置抛出,由原边界决定是否保留未就绪需求。 */ - public List requireSources() { if(error!=null) throw error;return sources; } - } /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { public Source requireSource() { if(error!=null) throw error; return source; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java index 0d8464783..76f4d0d29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -60,14 +61,14 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { var requests=remote.subList(start,Math.min(start+200,remote.size())); timing.stage="FIRSTLEG"; requireToken(); try { var response=timing.measure(timing.firstleg,requests.size(),()->firstleg.facts(token,requests)); if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败"); - append(result,requests,response.data()); + run(Stage.FACTS_VALIDATE,()->append(result,requests,response.data())); } catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); } } groups.forEach((provider,requests)->{ for(int start=0; startprovider.read(batch))); } + try { var response=timing.measure(statistics,batch.size(),()->provider.read(batch));run(Stage.FACTS_VALIDATE,()->append(result,batch,response)); } catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); } } }); @@ -76,6 +77,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { } /** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */ private static final class FactTiming { + private final Span prepare=span(Stage.FACTS_PREPARE); private final long started=System.nanoTime(); private final CallStatistics firstleg=new CallStatistics(); private final Map providers=new LinkedHashMap<>(); @@ -92,20 +94,24 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { } /** 只统计真正尝试的提供端调用;校验及组装耗时保留在 totalMs 中。 */ private T measure(CallStatistics statistics,int requests,java.util.function.Supplier operation) { - statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime(); - try { return operation.get(); } - finally { statistics.elapsed+=System.nanoTime()-begin; } + prepare.close();statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime(); + try(var span=span(switch(stage){case "FIRSTLEG"->Stage.FACTS_FIRSTLEG;case "AppointmentWorkReceivingProvider"->Stage.FACTS_RECEIVING; + case "AppointmentWorkDeliveryProvider"->Stage.FACTS_DELIVERY;case "AppointmentWorkTransportProvider"->Stage.FACTS_TRANSPORT; + case "AppointmentWorkDirectTransportProvider"->Stage.FACTS_DIRECT_TRANSPORT;default->Stage.FACTS_OTHER;})){ + try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} + }finally{statistics.elapsed+=System.nanoTime()-begin;} } /** 成功慢调用或任意失败只输出一次汇总,日志故障不得遮蔽原异常。 */ private void finish(boolean success) { + if(!success)prepare.failed();prepare.close(); long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started); if(success && totalMs<250) return; try { var summaries=new LinkedHashMap(); providers.forEach((name,value)->summaries.put(name,value.summary())); - LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}", - totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage); - } catch(RuntimeException diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ } + LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}, requestId={}, traceId={}, scopeId={}", + totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage,org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId()); + } catch(Throwable diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ } } } /** 仅包含数量和单调时钟耗时的累加器。 */ @@ -117,7 +123,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); } } /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ - public RequirementReader openRequirements(List inputs) { + public RequirementReader openRequirements() { return new RequirementReader() { private RequirementReader reader; /** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */ @@ -125,7 +131,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { if(reader==null) { var provider=requirements.getIfAvailable(); if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); - reader=provider.openRequirements(inputs); + reader=provider.openRequirements(); } var result=reader.read(source,effectiveFlow); if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java index 5912bbae3..e2af776af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -28,7 +28,7 @@ public interface AppointmentWorkFactGateway { SourcePage discover(String after, int size); ContainerSource container(String containerBookingId); /** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */ - RequirementReader openRequirements(List inputs); + RequirementReader openRequirements(); Map facts(List states); Owner owner(String id, Actor actor); Responsibility supplier(String id); @@ -43,6 +43,5 @@ public interface AppointmentWorkFactGateway { interface SupplierDirectory { Responsibility activeSupplier(String id); } /** 单次生成显式复用目录,不跨请求保存来源事实。 */ interface RequirementReader { List read(ContainerSource source,Flow effectiveFlow); } - record RequirementInput(ContainerSource source,Flow flow) { } - interface RequirementProvider { RequirementReader openRequirements(List inputs); } + interface RequirementProvider { RequirementReader openRequirements(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java index b8be03d3a..83e468a4e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java @@ -9,6 +9,7 @@ import java.util.*; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -26,10 +27,16 @@ public class AppointmentWorkQueryService { Snapshot snapshot(Actor actor) { return snapshot(actor,s->true,true); } + /** 按原顺序构造完整快照,计时只分离读取、筛选、事实及投影。 */ private Snapshot snapshot(Actor actor,Predicate selected,boolean includeNext) { + observeTransaction();var timing=span(Stage.QUERY_GUARD); + try { String scopeVersion=store.requireInitialized(); + timing.close();timing=span(Stage.QUERY_DISCOVER); if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步"); + timing.close();timing=span(Stage.QUERY_STORE); var all=store.all(); + timing.close();timing=span(Stage.QUERY_SELECT); var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet()); var needed=new HashSet<>(selectedKeys); @@ -41,12 +48,16 @@ public class AppointmentWorkQueryService { } } var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList(); + timing.close();timing=span(Stage.QUERY_FACTS); var current=facts.facts(states); + timing.close();timing=span(Stage.QUERY_PROJECT); var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList(); var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList(); var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity())); + timing.close();timing=span(Stage.QUERY_NEXT); return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey())) .map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList()); + } catch(RuntimeException|Error error){timing.failed();throw error;} finally{timing.close();} } private Action next(WorkItem item,Map items,Map states,Set visited) { if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction(); @@ -73,23 +84,34 @@ public class AppointmentWorkQueryService { } return item.nextAction(); } + /** 完整快照之后单独统计汇总耗时,不改变待办筛选语义。 */ public Summary summary(Actor actor) { - var items=snapshot(actor,s->true,false).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); + var snapshotItems=snapshot(actor,s->true,false).items(); + return measure(Stage.QUERY_FINAL,()->{ + var items=snapshotItems.stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count()); + }); } + /** 日历最终分组保持原顺序,单独记录快照后处理。 */ public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set types,Actor actor) { if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天"); if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看"); - var items=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) + var snapshotItems=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items(); + return measure(Stage.QUERY_FINAL,()->{ + var items=snapshotItems.stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) .filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList(); var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList())); var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList(); return new CalendarResult(from,to,items.size(),counts(items),days); + }); } + /** 保留事实核对之后的最终过滤排序分页,仅拆出其耗时。 */ public PageResult items(View view,int page,int size,LocalDate date,Flow flow,Set types,String containerId,Bucket bucket,String keyword,Actor actor) { if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效"); var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT); - var items=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items().stream() + var snapshotItems=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items(); + return measure(Stage.QUERY_FINAL,()->{ + var items=snapshotItems.stream() .filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED) .filter(i->date==null || date.equals(i.calendarDate())) .filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type())) @@ -100,6 +122,7 @@ public class AppointmentWorkQueryService { .sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList(); long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size()); return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size); + }); } public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) { if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键"); @@ -117,10 +140,12 @@ public class AppointmentWorkQueryService { var selected=candidates.get(0); return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates); } + /** 保留详情完整快照与退休事实读取,历史查询单独观察。 */ public WorkDetail detail(String key,Actor actor) { - var snapshot=snapshot(actor); var state=store.get(key); + var snapshot=snapshot(actor); var state=measure(Stage.DETAIL_STORE,()->store.get(key)); var fact=snapshot.facts().get(key); - if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key); + if(state.isRetired() && state.getType()!=Type.ROUTE) fact=measure(Stage.DETAIL_FACTS,()->facts.facts(List.of(state))).get(key); + try(var timing=span(Stage.DETAIL_PROJECT)) { if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源"); var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在")); var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys()); @@ -128,9 +153,10 @@ public class AppointmentWorkQueryService { relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey)); snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add); var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references()); - return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key), + return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),measure(Stage.DETAIL_HISTORY,()->store.history(key)), state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null, requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff()); + } } WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); } private boolean matches(AppointmentWorkState state,Flow flow,Set types,String containerId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java index e2439d69a..b447b19a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.stereotype.Component; import java.time.*; import java.util.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -21,13 +22,14 @@ public class AppointmentWorkStore { public void invalidateRequirements() { long started = System.nanoTime(); try { - if (mapper.invalidateRequirements() != 1) throw unavailable("工作事项同步守卫未初始化"); + if (measure(Stage.GUARD_UPDATE,mapper::invalidateRequirements) != 1) throw unavailable("工作事项同步守卫未初始化"); } finally { long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); if (elapsedMs >= 250) { // 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。 - LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}", - elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + try { LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId()); + } catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ } } } } @@ -35,20 +37,23 @@ public class AppointmentWorkStore { public AppointmentWorkMapper.Guard lock() { long started = System.nanoTime(); try { - var guard = mapper.lockGuard(); + observeTransaction(); + var guard = measure(Stage.GUARD_LOCK,mapper::lockGuard); if (guard == null) throw unavailable("工作事项DDL尚未集成"); return guard; } finally { long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); if (elapsedMs >= 250) { // 耗时包含连接及语句执行,单条日志不能区分具体等待原因,不记录业务载荷。 - LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}", - elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + try { LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId()); + } catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ } } } } + /** 完整性守卫保持原语义,读取耗时与事务完成只作诊断。 */ public String requireInitialized() { - var guard=mapper.guard(); + observeTransaction();var guard=measure(Stage.GUARD_READ,mapper::guard); if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项"); if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单"); return guard.getScopeVersion(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index f668a6fe6..4ef7a025d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -8,6 +8,7 @@ import java.time.*; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -23,7 +24,7 @@ public class AppointmentWorkSyncService { @Transactional public SyncResult synchronize(SyncRequest request) { if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜"); - var timing=new SyncTiming();boolean success=false; + observeTransaction();var timing=new SyncTiming();boolean success=false; try { var guard=store.lock(); if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对"); @@ -36,16 +37,7 @@ public class AppointmentWorkSyncService { timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); - var requirementInputs=page.containers().stream().filter(source->{ - // 坏来源仍由下面原顺序校验并报错,不能在后页原料提示阶段抢先覆盖前柜错误。 - try { validateSource(source);return true; } catch(RuntimeException invalid) { return false; } - }).map(source->{ - var flow=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))) - .filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(source.establishedFlow()); - return new RequirementInput(source,flow); - }).filter(input->input.flow()!=Flow.UNKNOWN).toList(); - // 提示仅用于原料批量读取,下面逐柜 existing/activeFlow 仍根据最新工作集决定。 - var requirementReader=gateway.openRequirements(requirementInputs); + var requirementReader=gateway.openRequirements(); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); @@ -97,6 +89,8 @@ public class AppointmentWorkSyncService { private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_STORE, ACTIVE_FACTS, OBSERVE_CHECKPOINT } /** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */ private static final class SyncTiming { + private final Span body=span(Stage.SYNC_BODY); + private Span diagnosticPhase=span(Stage.SYNC_GUARD); private final long started=System.nanoTime(); private final long[] elapsed=new long[SyncPhase.values().length]; private long phaseStarted=started; @@ -106,19 +100,24 @@ public class AppointmentWorkSyncService { /** 完成前一阶段后开始下一阶段;事实阶段包含紧邻的数据库回读。 */ private void next(SyncPhase next) { long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;phaseStarted=now;phase=next; + diagnosticPhase.close();diagnosticPhase=span(switch(next){ + case GUARD->Stage.SYNC_GUARD;case DISCOVER->Stage.SYNC_DISCOVER;case GENERATE->Stage.SYNC_GENERATE; + case TRANSPORT_FACTS->Stage.SYNC_TRANSPORT_FACTS;case RECONCILE->Stage.SYNC_RECONCILE; + case ACTIVE_STORE->Stage.SYNC_ACTIVE_STORE;case ACTIVE_FACTS->Stage.SYNC_ACTIVE_FACTS;case OBSERVE_CHECKPOINT->Stage.SYNC_CHECKPOINT;}); } /** 成功和失败共用一条慢调用摘要,未到达的计数保持为负一。 */ private void finish(boolean success) { long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted; + if(!success){diagnosticPhase.failed();body.failed();}diagnosticPhase.close();body.close(); long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started); if(totalMs<250) return; try { // 旧 activeFactsMs 仍包含工作表读取、解析及适配;新增明细不能改变旧指标口径。 long activeMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[SyncPhase.ACTIVE_STORE.ordinal()]+elapsed[SyncPhase.ACTIVE_FACTS.ordinal()]); - LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}", + LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}, requestId={}, traceId={}, scopeId={}", totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),activeMs,millis(SyncPhase.ACTIVE_STORE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT), - pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name()); - } catch(RuntimeException diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ } + pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name(),org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId()); + } catch(Throwable diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ } } /** 阶段仅输出整数毫秒,不携带业务标识。 */ private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); } @@ -140,7 +139,7 @@ public class AppointmentWorkSyncService { } /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { - return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements(List.of(new RequirementInput(source,flow)))); + return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements()); } /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ private Map workingSet() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java new file mode 100644 index 000000000..8d491f4fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import com.zaxxer.hikari.HikariDataSource; +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.config.BeanPostProcessor; +import javax.sql.DataSource; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** 仅观察原Hikari getConnection;同池同连接,unwrap保持容器bean self契约而非隐藏target身份。 */ +final class AppointmentWorkDataSourcePostProcessor implements BeanPostProcessor { + /** 不识别或已有代理的数据源保持原对象,明确记录未覆盖而不主动探测连接。 */ + @Override public Object postProcessAfterInitialization(Object bean,String name) { + if(!(bean instanceof DataSource))return bean; + if(!(bean instanceof HikariDataSource) || AopUtils.isAopProxy(bean)) { + warnUnavailable();return bean; + } + try { + var factory=new ProxyFactory(bean);factory.setProxyTargetClass(true); + factory.addAdvice((MethodInterceptor)invocation->{ + if(!invocation.getMethod().getName().equals("getConnection") || !active())return invocation.proceed(); + try(var span=span(Stage.CONNECTION_ACQUIRE)) { + try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;} + } + }); + return factory.getProxy(bean.getClass().getClassLoader()); + } catch(RuntimeException failure) { warnUnavailable();return bean; } + } + /** 不输出 Bean 名、数据源属性或异常内容。 */ + private static void warnUnavailable() { + try{org.slf4j.LoggerFactory.getLogger(AppointmentWorkDataSourcePostProcessor.class).warn("Appointment connection diagnostic unavailable: unsupported or already proxied datasource");} + catch(Throwable ignored){ /* 日志故障不改变 Bean 注册。 */ } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java new file mode 100644 index 000000000..72d04917e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** 仅测量服务端同步 filter dispatch,不代表客户端总耗时或异步响应完成时间。 */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE+10) +public class AppointmentWorkDiagnosticFilter extends OncePerRequestFilter { + private static final String BASE="/api/platform/v1/last-leg/appointment-work"; + /** 仅识别固定模板;路径参数、query string和载荷不进入上下文。 */ + static Route route(String method,String path){ + if("GET".equals(method)){ + if((BASE+"/summary").equals(path))return Route.SUMMARY; + if((BASE+"/calendar").equals(path))return Route.CALENDAR; + if((BASE+"/items").equals(path))return Route.ITEMS; + if((BASE+"/items/resolve").equals(path))return Route.RESOLVE; + if(path!=null && path.matches(BASE+"/items/[^/]+"))return Route.DETAIL; + } + if("POST".equals(method) && path!=null && path.matches(BASE+"/items/[^/]+/commands"))return Route.COMMAND; + return null; + } + /** 不采集其他端点,也不为诊断增加权限判断。 */ + @Override protected boolean shouldNotFilter(HttpServletRequest request){return route(request.getMethod(),request.getRequestURI())==null;} + /** 创建及清理线程作用域;不覆写上游相关标识、状态、响应或异常。 */ + @Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{ + Scope scope=null; + try{scope=open(Origin.HTTP,route(request.getMethod(),request.getRequestURI()));}catch(RuntimeException ignored){ /* 无诊断仍执行原请求。 */ } + try{chain.doFilter(request,response);} + catch(ServletException|IOException|RuntimeException|Error error){if(scope!=null)scope.failed();throw error;} + finally{if(scope!=null){try{scope.status(response.getStatus());}catch(Throwable ignored){ /* 响应诊断读取失败不替换原异常。 */ }finally{scope.close();}}} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java new file mode 100644 index 000000000..25be649fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java @@ -0,0 +1,128 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import java.util.*; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** 仅在当前线程的预约调用内累加固定计时桶,不保存业务参数或跨请求状态。 */ +public final class AppointmentWorkDiagnostics { + public enum Origin { HTTP, QUERY, COMMAND, SYNC } + public enum Route { INTERNAL, SUMMARY, CALENDAR, ITEMS, RESOLVE, DETAIL, COMMAND } + public enum Stage { + TRANSACTION_ADVISOR, QUERY_GUARD, QUERY_DISCOVER, QUERY_STORE, QUERY_SELECT, QUERY_FACTS, + QUERY_PROJECT, QUERY_NEXT, QUERY_FINAL, DETAIL_STORE, DETAIL_FACTS, DETAIL_PROJECT, DETAIL_HISTORY, + GUARD_LOCK, GUARD_READ, GUARD_UPDATE, FACTS_PREPARE, FACTS_FIRSTLEG, FACTS_RECEIVING, + FACTS_DELIVERY, FACTS_TRANSPORT, FACTS_DIRECT_TRANSPORT, FACTS_OTHER, FACTS_VALIDATE, + DELIVERY_SOURCE_MAP, DELIVERY_CARGO, DELIVERY_SNAPSHOTS, DELIVERY_ROWS, DELIVERY_RECEIVING, + DELIVERY_IMPACTS, DELIVERY_ASSEMBLY, SYNC_BODY, SYNC_GUARD, SYNC_DISCOVER, SYNC_GENERATE, + SYNC_TRANSPORT_FACTS, SYNC_RECONCILE, SYNC_ACTIVE_STORE, SYNC_ACTIVE_FACTS, SYNC_CHECKPOINT, CONNECTION_ACQUIRE, + DB_EXECUTOR_QUERY, DB_EXECUTOR_UPDATE, DB_PREPARE, DB_STATEMENT_QUERY, DB_STATEMENT_UPDATE, DB_RESULT_MAPPING + } + public enum StatementBucket { NONE, GUARD_LOCK, GUARD_READ, GUARD_UPDATE, WORK_STATE, WORK_HISTORY, DELIVERY, RECEIVING, OTHER } + public record Metric(long calls,long inclusiveNanos,long selfNanos,long failures,long firstOffsetNanos,long lastOffsetNanos) { } + public record Snapshot(String scopeId,String parentScopeId,Origin origin,Route route,String requestId,String traceId, + long elapsedNanos,boolean failed,int status,Map metrics,boolean transactionObserved,int completionStatus,long completionOffsetNanos,long startedNanos) { } + private static final org.slf4j.Logger LOG=LoggerFactory.getLogger(AppointmentWorkDiagnostics.class); + private static final ThreadLocal CURRENT=new ThreadLocal<>(); + private static final Span NO_SPAN=new Span(null,null,null,0); + /** 诊断工具无需实例化。 */ + private AppointmentWorkDiagnostics() { } + + /** 新建独立作用域;关闭时恢复调用方原作用域,不改变 MDC。 */ + public static Scope open(Origin origin,Route route) { return new Scope(origin,route,System::nanoTime); } + /** 返回纯诊断标识,缺少作用域时明确为空,不生成业务关联键。 */ + public static String currentScopeId() { var scope=CURRENT.get();return scope==null?null:scope.id; } + /** 无作用域时所有阶段计时均为空操作。 */ + public static boolean active() { return CURRENT.get()!=null; } + /** 只监听既有事务完成,不开启事务或改变其同步、隔离级别及连接属性。 */ + public static void observeTransaction() { + try { + var scope=CURRENT.get(); + if(scope==null || scope.transactionObserved || !org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive() + || !org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())return; + org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization(){ + /** 回调发生在原事务完成后,仅记录相对时刻;不声称这是数据库精确释放锁时刻。 */ + @Override public void afterCompletion(int status){try{scope.completionStatus=status;scope.completionOffsetNanos=Math.max(0,scope.clock.getAsLong()-scope.started);}catch(Throwable ignored){}} + }); + scope.transactionObserved=true; + }catch(Throwable ignored){ /* 诊断监听失败不能改变事务。 */ } + } + /** 将可信相关标识限制为诊断字符集;任意文本不进入日志。 */ + private static String correlation(String value) { return value!=null && value.matches("[A-Za-z0-9_-]{1,80}")?value:null; } + /** 固定阶段开始计时;最多保留64层,避免异常递归造成诊断状态膨胀。 */ + public static Span span(Stage stage) { return span(stage,StatementBucket.NONE); } + /** 数据库阶段只接受固定语句桶,不记录 SQL、参数或动态 Mapper 名称。 */ + public static Span span(Stage stage,StatementBucket bucket) { + try { + var scope=CURRENT.get();if(scope==null || scope.stack.size()>=64)return NO_SPAN; + var span=new Span(scope,stage,bucket,scope.clock.getAsLong());scope.stack.push(span);return span; + } catch(RuntimeException ignored) { return NO_SPAN; } + } + /** 保留返回对象和原异常;测量只包围原有调用一次。 */ + public static T measure(Stage stage,Supplier operation) { + try(var span=span(stage)) { try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} } + } + /** 无返回值调用沿相同路径执行,不增加重试或业务判断。 */ + public static void run(Stage stage,Runnable operation) { measure(stage,()->{operation.run();return null;}); } + /** 读取当前统计仅用于固定诊断摘要和隔离验证。 */ + static Snapshot currentSnapshot() { var scope=CURRENT.get();return scope==null?null:scope.snapshot(); } + + public static final class Scope implements AutoCloseable { + private final Scope previous; + private final String id=UUID.randomUUID().toString(); + private final Origin origin;private final Route route;private final LongSupplier clock; + private final String requestId,traceId;private final long started; + private final Deque stack=new ArrayDeque<>(); + private final Map totals=new LinkedHashMap<>(); + private boolean failed,closed,transactionObserved;private int status,completionStatus=-1;private long completionOffsetNanos=-1; + /** 包内时钟注入仅供确定性验证,不修改全局时钟。 */ + Scope(Origin origin,Route route,LongSupplier clock) { + this.previous=CURRENT.get();this.origin=origin;this.route=route;this.clock=clock;this.started=clock.getAsLong(); + this.requestId=correlation(MDC.get("requestId"));this.traceId=correlation(MDC.get("traceId"));CURRENT.set(this); + } + /** 只标记失败,不保存异常类型以外的业务内容。 */ + public void failed() { failed=true; } + /** HTTP状态由容器响应读取,业务code不从载荷解析。 */ + public void status(int status) { this.status=status; } + /** 快照为累计已结束阶段;self仅扣同scope直接子段,独立子scope由parentScopeId关联且不可叠加。 */ + public Snapshot snapshot() { + var metrics=new LinkedHashMap();totals.forEach((key,v)->metrics.put(key,new Metric(v[0],v[1],v[2],v[3],v[4],v[5]))); + return new Snapshot(id,previous==null?null:previous.id,origin,route,requestId,traceId,Math.max(0,clock.getAsLong()-started),failed,status,Collections.unmodifiableMap(metrics),transactionObserved,completionStatus,completionOffsetNanos,started); + } + /** 只有慢调用或失败输出汇总;清理优先,日志异常不得覆盖原业务异常。 */ + @Override public void close() { + if(closed)return;closed=true; + try { + if(CURRENT.get()==this){if(previous==null)CURRENT.remove();else CURRENT.set(previous);} + var s=snapshot();if(!s.failed() && s.status()<500 && s.elapsedNanos()<250_000_000L)return; + // startedNanos及offset只可在同一JVM进程比较,不是数据库锁持有者或跨进程时钟。 + LOG.warn("Appointment attribution: scopeId={}, parentScopeId={}, origin={}, route={}, requestId={}, traceId={}, startedNanos={}, elapsedNs={}, failed={}, httpStatus={}, connectionCoverage={}, transactionObserved={}, completionStatus={}, completionOffsetNs={}, boundary=SERVER_DISPATCH_OR_ADVISOR, stages={}", + s.scopeId(),s.parentScopeId(),s.origin(),s.route(),s.requestId(),s.traceId(),s.startedNanos(),s.elapsedNanos(),s.failed(),s.status(), + s.metrics().containsKey(Stage.CONNECTION_ACQUIRE.name())?"OBSERVED":"NOT_OBSERVED",s.transactionObserved(),s.completionStatus(),s.completionOffsetNanos(),s.metrics()); + } catch(Throwable ignored) { /* 诊断设施故障不影响返回、原异常或线程清理。 */ } + } + } + + public static final class Span implements AutoCloseable { + private final Scope scope;private final Stage stage;private final StatementBucket bucket;private final long started; + private long children;private boolean failed,closed; + /** 保存常量阶段,不引用任何业务对象。 */ + private Span(Scope scope,Stage stage,StatementBucket bucket,long started){this.scope=scope;this.stage=stage;this.bucket=bucket;this.started=started;} + /** 失败计数只作为耗时解释,不改变原异常。 */ + public void failed(){if(scope!=null){failed=true;scope.failed=true;}} + /** 每个阶段只结束一次;父段self扣除子段inclusive,不能把两种口径相加。 */ + @Override public void close(){ + if(scope==null || closed)return;closed=true; + try{ + long elapsed=Math.max(0,scope.clock.getAsLong()-started); + if(scope.stack.peek()!=this)return; + scope.stack.pop();if(!scope.stack.isEmpty())scope.stack.peek().children+=elapsed; + var key=stage.name()+(bucket==StatementBucket.NONE?"":":"+bucket.name()); + var v=scope.totals.computeIfAbsent(key,ignored->new long[]{0,0,0,0,Math.max(0,started-scope.started),0});v[0]++;v[1]+=elapsed;v[2]+=Math.max(0,elapsed-children);if(failed)v[3]++; + v[4]=Math.min(v[4],Math.max(0,started-scope.started));v[5]=Math.max(v[5],Math.max(0,started+elapsed-scope.started)); + }catch(Throwable ignored){ /* 诊断计数故障不能替换原结果。 */ } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java new file mode 100644 index 000000000..b47339767 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import com.baiyue.lastleg.appointmentwork.*; +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.core.Ordered; +import java.lang.reflect.Method; +import java.util.Set; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** 使用现有 Spring 代理设施观察事务边界,不新增 AspectJ 或改变事务属性。 */ +@Configuration(proxyBeanMethods=false) +public class AppointmentWorkDiagnosticsConfiguration { + /** 基础设施角色确保事务自动代理器纳入此 Advisor;顺序早于默认事务 Advisor。 */ + @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public Advisor appointmentWorkDiagnosticAdvisor() { + var pointcut=new StaticMethodMatcherPointcut() { + /** 仅匹配预约直接入口,不拦截其他模块或内部辅助方法。 */ + @Override public boolean matches(Method method,Class targetClass) { + return AppointmentWorkSyncService.class.isAssignableFrom(targetClass) && method.getName().equals("synchronize") + || AppointmentWorkCommandService.class.isAssignableFrom(targetClass) && method.getName().equals("execute") + || AppointmentWorkQueryService.class.isAssignableFrom(targetClass) && Set.of("summary","calendar","items","resolveBatch","detail").contains(method.getName()); + } + }; + MethodInterceptor interceptor=invocation->{ + boolean sync=invocation.getThis() instanceof AppointmentWorkSyncService; + Origin origin=sync?Origin.SYNC:invocation.getThis() instanceof AppointmentWorkCommandService?Origin.COMMAND:Origin.QUERY; + Scope owned=null; + try { if(sync || !active())owned=open(origin,Route.INTERNAL); } catch(RuntimeException ignored) { /* 无法计时仍执行原方法。 */ } + try(var span=span(Stage.TRANSACTION_ADVISOR)) { + try{return invocation.proceed();}catch(Throwable error){span.failed();if(owned!=null)owned.failed();throw error;} + } finally { if(owned!=null)owned.close(); } + }; + var advisor=new DefaultPointcutAdvisor(pointcut,interceptor);advisor.setOrder(Ordered.HIGHEST_PRECEDENCE+100);return advisor; + } + /** 静态 Bean 避免提早实例化业务依赖,只对既有 Hikari 实例增加委托代理。 */ + @Bean public static BeanPostProcessor appointmentWorkDataSourceDiagnostics() { return new AppointmentWorkDataSourcePostProcessor(); } + /** MyBatis 按现有插件入口注册,不注册 Mapper、执行 SQL 或改变缓存配置。 */ + @Bean public AppointmentWorkMyBatisDiagnostics appointmentWorkMyBatisDiagnostics() { return new AppointmentWorkMyBatisDiagnostics(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java new file mode 100644 index 000000000..088ccabf8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.executor.resultset.ResultSetHandler; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.cache.CacheKey; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.session.*; +import java.sql.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** query 为含映射的 inclusive 调用;结果读取另记子段,不推断为纯服务器锁等待。 */ +@Intercepts({ + @Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}), + @Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class,CacheKey.class,BoundSql.class}), + @Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}), + @Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class}), + @Signature(type=StatementHandler.class,method="query",args={Statement.class,ResultHandler.class}), + @Signature(type=StatementHandler.class,method="update",args={Statement.class}), + @Signature(type=ResultSetHandler.class,method="handleResultSets",args={Statement.class}) +}) +public final class AppointmentWorkMyBatisDiagnostics implements Interceptor { + private static final ThreadLocal BUCKET=new ThreadLocal<>(); + /** 所有调用只 proceed 一次,异常原样返回;固定桶上下文在 finally 恢复。 */ + @Override public Object intercept(Invocation invocation)throws Throwable { + if(!active())return invocation.proceed(); + var previous=BUCKET.get();boolean executor=invocation.getTarget() instanceof Executor; + var bucket=previous==null?StatementBucket.OTHER:previous; + if(executor){bucket=bucket(((MappedStatement)invocation.getArgs()[0]).getId());BUCKET.set(bucket);} + Stage stage=executor?(invocation.getMethod().getName().equals("query")?Stage.DB_EXECUTOR_QUERY:Stage.DB_EXECUTOR_UPDATE) + :invocation.getTarget() instanceof ResultSetHandler?Stage.DB_RESULT_MAPPING + :switch(invocation.getMethod().getName()){case "prepare"->Stage.DB_PREPARE;case "query"->Stage.DB_STATEMENT_QUERY;default->Stage.DB_STATEMENT_UPDATE;}; + try(var span=span(stage,bucket)){ + try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;} + }finally{if(executor){if(previous==null)BUCKET.remove();else BUCKET.set(previous);}} + } + /** 只有明确白名单语句有独立桶,其他名字一律合入 OTHER,绝不打印原 ID。 */ + private static StatementBucket bucket(String id){ + return switch(id){ + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.lockGuard"->StatementBucket.GUARD_LOCK; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard"->StatementBucket.GUARD_READ; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.invalidateRequirements","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.saveGuard"->StatementBucket.GUARD_UPDATE; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.all","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.get"->StatementBucket.WORK_STATE; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.selectHistory"->StatementBucket.WORK_HISTORY; + case "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.batches","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.customer","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.privateBatch"->StatementBucket.DELIVERY; + default->StatementBucket.OTHER; + }; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 7a116f3f4..216503c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -28,28 +28,6 @@ public interface InboundFbaAppointmentQueryMapper { " ORDER BY appointment_id DESC LIMIT 1001"}) List relatedBatchIds(@Param("sources") List sources); - record RequirementSource(int groupOrdinal,String customerId,String sourceKey,String destinationKey) { } - record RequirementBatch(int groupOrdinal,long appointmentId) { } - /** 按原需求组返回历史归属,失活明细也保留;每组第1001条用于检测超限。 */ - @Select(""" - - """) - List requirementBatches(@Param("sources") List sources); - @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 30b1f5498..cec90b860 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -34,7 +34,6 @@ public class InboundFbaAppointmentService { @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork; @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges; - @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads; @Transactional(readOnly=true) public List calendar(LocalDate from,LocalDate to) { @@ -112,42 +111,6 @@ public class InboundFbaAppointmentService { if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"); return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids)); } - /** 同页原料只读一次,历史归属按原需求组保留;响应解析延后至原访问顺序。 */ - public java.util.function.IntFunction> prepareRelatedBatchSnapshots(List> groups) { - var errors=new HashMap(); - var requested=new ArrayList(); - for(int i=0;i100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) { - errors.put(i,bad("每次最多读取100个完整原来源的关联批次"));continue; - } - final int group=i; - refs.stream().distinct().forEach(ref->requested.add(new InboundFbaAppointmentQueryMapper.RequirementSource(group,ref.customerId(),ref.sourceKey(),ref.destinationKey()))); - } - var groupIds=new HashMap>(); - for(int start=0;start=groups.size()) throw new IllegalStateException("历史关联返回了请求外需求组"); - groupIds.computeIfAbsent(match.groupOrdinal(),ignored->new TreeSet<>()).add(match.appointmentId()); - } - } - groupIds.forEach((group,ids)->{if(ids.size()>1000) errors.put(group,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"));}); - var ids=groupIds.entrySet().stream().filter(e->!errors.containsKey(e.getKey())).flatMap(e->e.getValue().stream()).distinct().sorted().toList(); - var rows=new LinkedHashMap(); - var items=new HashMap>(); - for(int start=0;startrows.put(row.getId(),row)); - requirementReads.items(batch).forEach(item->items.computeIfAbsent(item.getAppointmentId(),ignored->new ArrayList<>()).add(item)); - } - return group->{ - if(group<0 || group>=groups.size()) throw new IllegalArgumentException("需求组不属于本次读取范围"); - if(errors.containsKey(group)) throw errors.get(group); - var selected=groupIds.getOrDefault(group,Set.of()); - return rows.values().stream().filter(row->selected.contains(row.getId())).map(row->response(row,items.getOrDefault(row.getId(),List.of()))).toList(); - }; - } @Transactional(readOnly=true) public FbaAppointmentBatchContext batchContext(String key) { final long id; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java deleted file mode 100644 index c25768b2e..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.lastleg.inboundplan.work; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.lastleg.inboundplan.entity.*; -import com.baiyue.lastleg.inboundplan.mapper.*; -import org.springframework.stereotype.Repository; -import java.util.List; - -/** 需求生成的本域原料读取,历史归属不能从活动响应明细反推。 */ -@Repository -public class AppointmentRequirementReadRepository { - private final AppointmentWorkSourceMapper mapping; - private final InboundFbaAppointmentQueryMapper queries; - private final InboundFbaAppointmentMapper appointments; - private final InboundFbaAppointmentItemMapper items; - /** 保留本域 Mapper 与客户来源查询边界。 */ - public AppointmentRequirementReadRepository(AppointmentWorkSourceMapper mapping,InboundFbaAppointmentQueryMapper queries,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items) { - this.mapping=mapping;this.queries=queries;this.appointments=appointments;this.items=items; - } - /** 每柜独立上限由既有窗口查询保持。 */ - public List sources(List containers) { return containers.isEmpty()?List.of():mapping.containerSourcesBatch(containers); } - /** 分块只控制单条 SQL 输入,业务服务仍按需求组完整合并及核对上限。 */ - public List history(List sources) { return sources.isEmpty()?List.of():queries.requirementBatches(sources); } - /** 读取已确认关联的预约主记录。 */ - public List appointments(List ids) { return ids.isEmpty()?List.of():appointments.selectBatchIds(ids); } - /** 包括失活明细以维持取消与替代历史;响应过滤由业务服务执行。 */ - public List items(List ids) { return ids.isEmpty()?List.of():items.selectList(new LambdaQueryWrapper().in(InboundFbaAppointmentItem::getAppointmentId,ids).orderByAsc(InboundFbaAppointmentItem::getId)); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java index bf80098b6..611519640 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java @@ -8,6 +8,7 @@ import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving; import com.baiyue.lastleg.security.OverseasPermissions; import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import java.util.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; @@ -26,11 +27,12 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;this.receiving=receiving; } public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; } + /** 按原批次顺序读取,仅对固定步骤附加耗时,不更改来源或失败语义。 */ public List read(List requests) { var mapped=new HashMap>(); var privateSources=new HashMap>(); var containers=requests.stream().filter(r->r.batchKey()==null && r.requirementKey()!=null && r.requirementKey().startsWith("INBOUND:")).map(FactRequest::containerBookingId).distinct().toList(); - if(!containers.isEmpty()) sourceMapping.containerSourcesBatch(containers).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source())); + if(!containers.isEmpty()) measure(Stage.DELIVERY_SOURCE_MAP,()->sourceMapping.containerSourcesBatch(containers)).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source())); for(var request:requests) if(request.batchKey()==null && request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) { var sources=mapped.getOrDefault(request.containerBookingId(),List.of()); if(sources.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); @@ -40,20 +42,20 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew var refs=requests.stream().filter(r->r.batchKey()==null && !privateSources.containsKey(r.workItemKey())).flatMap(r->r.sources().stream()) .map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); var inspections=new HashMap(); - if(!refs.isEmpty()) cargo.inspectBatch(refs).forEach(i->inspections.put(i.reference(),i)); + if(!refs.isEmpty()) measure(Stage.DELIVERY_CARGO,()->cargo.inspectBatch(refs)).forEach(i->inspections.put(i.reference(),i)); var batches=requests.stream().map(FactRequest::batchKey).filter(k->k!=null && !k.startsWith("DISPATCH:")).distinct().toList(); var snapshots=new HashMap(); var deliveryRows=new HashMap>(); Map impacts=Map.of(); var receivingSnapshots=new HashMap(); if(!batches.isEmpty()) { - appointments.batchSnapshots(batches).forEach(a->snapshots.put(a.batchKey(),a)); - deliveries.batches(batches).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery())); - receiving.snapshots(batches).forEach(s->receivingSnapshots.put(s.batchKey(),s)); - impacts=commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList()); + measure(Stage.DELIVERY_SNAPSHOTS,()->appointments.batchSnapshots(batches)).forEach(a->snapshots.put(a.batchKey(),a)); + measure(Stage.DELIVERY_ROWS,()->deliveries.batches(batches)).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery())); + measure(Stage.DELIVERY_RECEIVING,()->receiving.snapshots(batches)).forEach(s->receivingSnapshots.put(s.batchKey(),s)); + impacts=measure(Stage.DELIVERY_IMPACTS,()->commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList())); } var context=new BatchFacts(snapshots,deliveryRows,impacts,receivingSnapshots); - return requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList(); + return measure(Stage.DELIVERY_ASSEMBLY,()->requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList()); } private record BatchFacts(Map appointments,Map> deliveries,Map impacts,Map receiving) { } private Fact read(FactRequest request,List privateSources,Map inspections,BatchFacts context) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java index 6bab0f177..db8e65d91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -17,85 +17,29 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa private final AppointmentCargoVerificationService cargo; private final InboundFbaAppointmentService appointments; private final AppointmentWorkSourceMapper sourceMapping; - @jakarta.annotation.Resource private AppointmentRequirementReadRepository reads; public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; } - /** 本次生成共享原料,实例不会跨同步页或独立命令保存。 */ - public RequirementReader openRequirements(List inputs) { + /** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */ + public RequirementReader openRequirements() { return new RequirementReader() { - private Prepared prepared; - /** 原料提示不替代实际路线判断;未提示的真实需求按同一实现即时读取。 */ + private AppointmentSourceService.ReadScope scope; + /** 直送和未建立尾程来源的需求不创建目录读取依赖。 */ public List read(ContainerSource container,Flow flow) { - if(flow==Flow.DIRECT) return original(container); - if(flow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); - if(prepared==null) { - var requested=new ArrayList<>(inputs);requested.add(new RequirementInput(container,flow)); - prepared=prepare(requested); - } - var data=prepared.containers.contains(container)?prepared:prepare(List.of(new RequirementInput(container,flow))); - return requirements(container,flow,data); + return requirements(container,flow,refs->{ + if(scope==null) scope=sources.openReadScope(); + return scope.resolveBatch(refs); + }); } }; } - /** 私人地址仍以原客户仓需求表示,直送不增加尾程读取依赖。 */ - private List original(ContainerSource container) { - return container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); - } - private record Group(ContainerSource container,String requirementKey) { } - /** 只含本次原料及延后执行器,不缓存已经解析的业务响应。 */ - private static final class Prepared { - private final Set containers=new LinkedHashSet<>(); - private final Map> mapped=new HashMap<>(); - private final Map ordinals=new LinkedHashMap<>(); - private List sources=List.of(); - private RuntimeException mappingError,sourceError; - private java.util.function.IntFunction> history; - private java.util.function.Function inspection; - } - /** 先批量读取原料,组级业务错误在原需求访问位置抛出。 */ - private Prepared prepare(List inputs) { - var data=new Prepared(); - inputs.stream().filter(i->i.flow()==Flow.TRANSFER).map(RequirementInput::source).forEach(data.containers::add); - var ids=data.containers.stream().map(ContainerSource::containerBookingId).distinct().toList(); - try { - for(int start=0;startdata.mapped.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.source())); - } catch(RuntimeException error) { data.mappingError=error;return data; } - var groups=new ArrayList>(); - for(var container:data.containers) { - var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); - if(mapped.size()>1000) continue; - for(var requirement:original(container)) { - if(!"FBA".equals(requirement.destinationKind())) continue; - var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null; - var orders=mapped.stream().filter(row->Objects.equals(row.sourceInboundId(),inbound)).toList(); - if(orders.isEmpty()) continue; - data.ordinals.put(new Group(container,requirement.requirementKey()),groups.size()); - groups.add(orders.stream().map(row->new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.id())).toList()); - } - } - if(groups.isEmpty()) return data; - try { data.sources=sources.openReadScope().resolveOrderGroups(groups); } - catch(RuntimeException error) { data.sourceError=error;return data; } - var historyGroups=data.sources.stream().map(group->group.error()==null?group.sources().stream().map(source->new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList():List.of()).toList(); - var valid=data.sources.stream().filter(group->group.error()==null).flatMap(group->group.sources().stream()).toList(); - if(valid.isEmpty()) return data; - try { data.history=appointments.prepareRelatedBatchSnapshots(historyGroups); } - catch(RuntimeException error) { data.history=ignored->{throw error;}; } - // 不提前解析货件,仍由原 history -> anyMatch 路径按实际访问顺序执行。 - try { data.inspection=cargo.prepareInspection(valid); } - catch(RuntimeException error) { data.inspection=ignored->{throw error;}; } - return data; - } /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ private List requirements(ContainerSource container,Flow effectiveFlow, - Prepared data) { - var original=original(container); + java.util.function.Function,List> resolver) { + var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); if(effectiveFlow==Flow.DIRECT) return original; if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); - if(data.mappingError!=null) throw data.mappingError; - var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); + var mapped=sourceMapping.containerSources(container.containerBookingId()); if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); var result=new LinkedHashMap(); var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.of(); @@ -106,13 +50,9 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; } if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; } if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } - if(sourceOrders.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); - if(data.sourceError!=null) throw data.sourceError; - int ordinal=data.ordinals.get(new Group(container,requirement.requirementKey())); - var group=data.sources.get(ordinal); - var refs=group.references(); + var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); List resolved; - try { resolved=group.requireSources(); } + try { resolved=resolver.apply(refs); } catch(org.springframework.web.server.ResponseStatusException error) { if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; // Keep the complete physical obligation and its canonical source identities. The fact @@ -123,10 +63,10 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa continue; } var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList(); - var history=data.history.apply(ordinal); + var history=appointments.relatedBatchSnapshots(complete); var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList(); // Unknown remaining quantity is still work, never a zero or an absent requirement. - boolean remainder=resolved.stream().map(data.inspection).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); + boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); if(remainder) { var restored=new LinkedHashSet<>(replaced); @@ -188,6 +128,18 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList())); } } + private List canonical(List input) { + if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); + var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{ + try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); } + catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); } + }).toList(); + var result=new ArrayList(); + if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders)); + input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add); + if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); + return result.stream().distinct().toList(); + } /** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */ private static boolean active(FbaAppointmentResponse batch) { if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java index 8eced830c..ea89cc6e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java @@ -11,22 +11,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentCargoVerificationBatchTest { - /** 预取只读原料,未被 anyMatch 访问的坏 JSON 不提前报错;下一次准备仍重新读取。 */ - @Test void preparedAuthorizedSourcesKeepShortCircuitAndBoundedReads() { - var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); - var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); - org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentCargoReadRepository(mapper)); - var resolved=IntStream.rangeClosed(1,201).mapToObj(i->new AppointmentSourceService.Source("ORDER:"+i,"C",(long)i,"IN",null,"CNT","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())).toList(); - var broken=new AppointmentCargoVerification();broken.setCustomerId("C");broken.setSourceKey("ORDER:2");broken.setPackageCount(10);broken.setShipmentReferencesJson("broken-json"); - when(mapper.latestBatch(anyList())).thenAnswer(c->c.>getArgument(0).stream().anyMatch(r->r.sourceKey().equals("ORDER:2"))?List.of(broken):List.of()); - var read=service.prepareInspection(resolved); - assertTrue(resolved.stream().map(read).anyMatch(c->c.availablePackages()!=null && c.availablePackages()>0)); - assertThrows(RuntimeException.class,()->read.apply(resolved.get(1))); - assertEquals(10L,read.apply(resolved.get(200)).availablePackages()); - verify(mapper,times(3)).latestBatch(anyList());verify(mapper,times(3)).allocatedBatch(anyList());verifyNoInteractions(sources); - service.prepareInspection(resolved);verify(mapper,times(6)).latestBatch(anyList());verify(mapper,times(6)).allocatedBatch(anyList()); - assertThrows(IllegalArgumentException.class,()->read.apply(new AppointmentSourceService.Source("ORDER:1","OTHER",1L,null,null,null,"100",null,"FBA:ONT8","ONT8",10,"v1",true,List.of()))); - } @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index be51c5bf3..13e21e691 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -146,10 +146,6 @@ class AppointmentPlanningAuthorizationTest { @Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);} @Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); } @Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); } - /** 权限测试只模拟业务服务,补齐其新增只读端口依赖而不触发数据库。 */ - @Bean AppointmentSourceReadRepository sourceReads() { return mock(AppointmentSourceReadRepository.class); } - /** 本测试验证入口权限,不执行货物核实原料读取。 */ - @Bean AppointmentCargoReadRepository cargoReads() { return mock(AppointmentCargoReadRepository.class); } @Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); } @Bean AppointmentBatchPreviewController batchPreviewController(AppointmentBatchPreviewService service) { return new AppointmentBatchPreviewController(service); } @Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java index ee5ff8d4d..3082da1b6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -12,56 +12,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentSourceServiceTest { - /** 同页共享原料但保留逐组权限、重复和前置错误,结果等于各组独立严格读取。 */ - @Test void groupedOrderResolutionSharesReadsWithoutSharingValidationOrFailures() { - var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); - var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); - var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); - org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); - var a=new LastlegOrder();a.setId(1L);a.setCustomerId("A");a.setOrderType("FIRST_LEG");a.setSourceContainerBookingId("C1");a.setOverseasWarehouseId("100");a.setDestinationType("FBA");a.setFbaWarehouseCode("ONT8");a.setPackageCount(10); - var b=new LastlegOrder();b.setId(2L);b.setCustomerId("B");b.setOrderType("FIRST_LEG");b.setSourceContainerBookingId("C2");b.setOverseasWarehouseId("100");b.setDestinationType("FBA");b.setFbaWarehouseCode("ONT8");b.setPackageCount(20); - when(orders.selectBatchIds(anyCollection())).thenAnswer(c->List.of(a,b).stream().filter(o->c.>getArgument(0).contains(o.getId())).toList()); - var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40); - when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate)); - when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); - var ref=new AppointmentExternalSourceGateway.OrderRef("A",1L); - var groups=List.of(List.of(ref),List.of(ref),List.of(new AppointmentExternalSourceGateway.OrderRef("B",2L)),List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)),List.of(ref,ref)); - var actual=service.openReadScope().resolveOrderGroups(groups); - assertEquals(actual.get(0).requireSources(),actual.get(1).requireSources()); - assertEquals("货物尚无已开船或后续实际船节点",((ResponseStatusException)actual.get(2).error()).getReason()); - assertEquals(404,((ResponseStatusException)actual.get(3).error()).getStatusCode().value()); - assertEquals(503,((ResponseStatusException)actual.get(4).error()).getStatusCode().value()); - assertEquals("来源键必须明确为ORDER或BOOKING",((ResponseStatusException)actual.get(4).error()).getReason()); - verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(external,times(1)).resolveOrders(anyList());verify(warehouse,times(1)).catalog("token"); - assertEquals(service.resolveBatch(actual.get(0).references()),actual.get(0).requireSources()); - assertEquals(400,((ResponseStatusException)service.openReadScope().resolveOrderGroups(List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("x".repeat(129),1L)))).get(0).error()).getStatusCode().value()); - var failure=new IllegalStateException("directory unavailable");when(warehouse.catalog("token")).thenThrow(failure); - clearInvocations(warehouse); - var failed=service.openReadScope().resolveOrderGroups(List.of(List.of(ref),List.of(ref))); - assertEquals(503,((ResponseStatusException)failed.get(0).error()).getStatusCode().value());assertSame(failed.get(0).error(),failed.get(1).error());verify(warehouse,times(1)).catalog("token"); - service.openReadScope().resolveOrderGroups(List.of(List.of(ref)));verify(warehouse,times(2)).catalog("token"); - } - /** 不同订单规范化成同登记仍合一,不同登记重复同实物订单仍拒绝。 */ - @Test void groupedCanonicalAliasesKeepOriginalDistinctAndMaterializedRules() { - var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); - var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); - org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); - var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10); - when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order)); - when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); - var booking=new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",1L,10,1L); - when(external.resolveOrders(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",2L,10,1L))); - when(external.resolve(anyList())).thenReturn(List.of(booking)); - var input=List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L),new AppointmentExternalSourceGateway.OrderRef("A",2L))); - assertEquals(1,service.openReadScope().resolveOrderGroups(input).get(0).requireSources().size()); - var alias2=new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",2L,10,1L); - when(external.resolveOrders(anyList())).thenReturn(List.of(booking,alias2)); - when(external.resolve(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); - assertEquals("同一货物存在重复来源,请保留原登记来源",((ResponseStatusException)service.openReadScope().resolveOrderGroups(input).get(0).error()).getReason()); - when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","x".repeat(161),null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); - var invalid=service.openReadScope().resolveOrderGroups(List.of(input.get(0).subList(0,1))).get(0); - assertEquals("请选择完整的真实货源",((ResponseStatusException)invalid.error()).getReason()); - } /** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */ @Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() { var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java index dc08f4468..e86ca683d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -10,6 +10,21 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; class AppointmentWorkFactAdapterTest { + /** 原事实分批沿请求作用域关联,provider计数和返回顺序保持不变。 */ + @Test void diagnosticScopeKeepsTraceAndOriginalProviderBatchCount() { + var f=new DiagnosticFixture();var states=new ArrayList(); + for(int i=0;i<201;i++)states.add(f.work.state("private-receiving-"+i,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null)); + org.slf4j.MDC.put("requestId","safe-request");org.slf4j.MDC.put("traceId","safe-trace"); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)){ + var result=f.adapter.facts(states);assertEquals(201,result.size());assertEquals(List.of(200,1),f.providerSizes); + assertEquals(2,scope.snapshot().metrics().get("FACTS_RECEIVING").calls()); + assertEquals(2,scope.snapshot().metrics().get("FACTS_VALIDATE").calls()); + assertEquals("safe-trace",scope.snapshot().traceId());assertEquals("safe-request",scope.snapshot().requestId()); + assertFalse(scope.snapshot().toString().contains("private-"));verifyNoInteractions(f.firstleg); + }finally{org.slf4j.MDC.clear();} + } /** 慢成功仅追加聚合诊断,原二百项批量、调用次序和事实结果保持一致。 */ @Test void slowSuccessSummarizesOriginalBatchesWithoutBusinessContent() { var f=new DiagnosticFixture(); @@ -103,10 +118,10 @@ class AppointmentWorkFactAdapterTest { var business=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); org.mockito.Mockito.when(business.orderedStream()).thenAnswer(c->java.util.stream.Stream.empty()); var adapter=new AppointmentWorkFactAdapter(null,business,null,providers,null,"token"); - var unused=adapter.openRequirements(java.util.List.of());org.mockito.Mockito.verifyNoInteractions(providers); + var unused=adapter.openRequirements();org.mockito.Mockito.verifyNoInteractions(providers); var source=new ContainerSource("1","CNT",java.time.Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of()); var f=new WorkTestFixture(); - org.mockito.Mockito.when(f.gateway.openRequirements(java.util.List.of())).thenAnswer(c->adapter.openRequirements(java.util.List.of())); + org.mockito.Mockito.when(f.gateway.openRequirements()).thenAnswer(c->adapter.openRequirements()); org.mockito.Mockito.when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.sync.synchronize(new SyncRequest(null,10));assertEquals(Type.ROUTE,f.store.all().get(0).getType()); org.mockito.Mockito.verifyNoInteractions(providers); @@ -114,14 +129,14 @@ class AppointmentWorkFactAdapterTest { var provider=org.mockito.Mockito.mock(RequirementProvider.class); var delegate=org.mockito.Mockito.mock(RequirementReader.class); org.mockito.Mockito.when(providers.getIfAvailable()).thenReturn(provider); - org.mockito.Mockito.when(provider.openRequirements(java.util.List.of())).thenReturn(delegate); + org.mockito.Mockito.when(provider.openRequirements()).thenReturn(delegate); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); org.mockito.Mockito.when(delegate.read(source,Flow.TRANSFER)).thenReturn(List.of(requirement),List.of(requirement),List.of()); - var reader=adapter.openRequirements(java.util.List.of()); + var reader=adapter.openRequirements(); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertThrows(ResponseStatusException.class,()->reader.read(source,Flow.TRANSFER)); - org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(java.util.List.of()); + org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(); } @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java index 172367892..af142038b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -79,7 +79,7 @@ class AppointmentWorkPersistenceTest { var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8"))); var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement)); var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class); - org.mockito.Mockito.when(facts.openRequirements(org.mockito.ArgumentMatchers.anyList())).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); + org.mockito.Mockito.when(facts.openRequirements()).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR)); assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate()); var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java index a0a419b3d..0665d195b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java @@ -12,6 +12,35 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkQueryServiceTest { + /** 开启计时不改变FOLLOW_TODAY分页、汇总与日历,最终后处理各自有独立桶。 */ + @Test void diagnosticScopePreservesRealListSummaryAndCalendarResults(){ + var f=new WorkTestFixture();var state=f.state("private-source",Type.PICKUP,Flow.TRANSFER,null);state.setFollowupDate(LocalDate.of(2026,10,14));f.add(state); + var expectedItems=f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR); + var expectedSummary=f.queries.summary(ACTOR);var from=LocalDate.of(2026,10,1);var to=LocalDate.of(2026,10,31); + var expectedCalendar=f.queries.calendar(from,to,null,null,ACTOR); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.ITEMS)){ + assertEquals(expectedItems,f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR)); + assertEquals(expectedSummary,f.queries.summary(ACTOR));assertEquals(expectedCalendar,f.queries.calendar(from,to,null,null,ACTOR)); + assertEquals(3,scope.snapshot().metrics().get("QUERY_FINAL").calls()); + } + } + /** 详情阶段计时不得增加事实、完整工作集或历史读取,也不改变返回值。 */ + @Test void diagnosticScopeMeasuresOriginalDetailStagesWithoutAdditionalReads() { + var f=new WorkTestFixture();var state=f.state("private-container",Type.PICKUP,Flow.TRANSFER,null);f.add(state); + var expected=f.queries.detail(state.getWorkItemKey(),ACTOR);clearInvocations(f.gateway,f.mapper); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)) { + assertEquals(expected,f.queries.detail(state.getWorkItemKey(),ACTOR)); + var metrics=scope.snapshot().metrics(); + for(var stage:List.of("QUERY_GUARD","QUERY_DISCOVER","QUERY_STORE","QUERY_SELECT","QUERY_FACTS","QUERY_PROJECT","QUERY_NEXT","DETAIL_STORE","DETAIL_HISTORY"))assertEquals(1,metrics.get(stage).calls(),stage); + assertFalse(scope.snapshot().toString().contains("private-container")); + verify(f.gateway).facts(anyList());verify(f.gateway).discover(null,1);verify(f.mapper).all(anyInt());verify(f.mapper).selectHistory(state.getWorkItemKey()); + } + assertFalse(com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.active()); + } @Test void sameBatchRequiresExplicitCustomerAndNeverChoosesTheFirstCandidate() { var f=new WorkTestFixture();var expected=new HashMap(); for(var customer:List.of("A","B")) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index 670828b74..e234e88cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -12,7 +12,20 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkSyncServiceTest { - /** 百柜同页批量读取来源原料,下页与独立命令重读,生成状态保持不变。 */ + /** 启用诊断不增加同步回读或事实批次,原完整发布结果保持一致。 */ + @Test void diagnosticScopePreservesOriginalSyncReadCountsAndResult(){ + var f=new WorkTestFixture();when(f.gateway.discover(null,100)).thenReturn(new SourcePage(List.of(),null,true,"scope")); + var expected=f.sync.synchronize(new SyncRequest(null,100));clearInvocations(f.gateway,f.mapper); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.SYNC, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.INTERNAL)){ + assertEquals(expected,f.sync.synchronize(new SyncRequest(null,100))); + var metrics=scope.snapshot().metrics(); + for(var stage:List.of("SYNC_BODY","SYNC_GUARD","SYNC_DISCOVER","SYNC_GENERATE","SYNC_TRANSPORT_FACTS","SYNC_RECONCILE","SYNC_ACTIVE_STORE","SYNC_ACTIVE_FACTS","SYNC_CHECKPOINT"))assertEquals(1,metrics.get(stage).calls(),stage); + verify(f.gateway,times(2)).facts(anyList());verify(f.mapper,times(3)).all(anyInt());verify(f.mapper).lockGuard();verify(f.mapper).saveGuard(any()); + } + } + /** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */ @Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() { var f=new WorkTestFixture(); var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); @@ -31,7 +44,6 @@ class AppointmentWorkSyncServiceTest { var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId(id);gate.setNodeSequence(40);return gate; }).toList()); var sources=new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService(orders,shipping,external,warehouse,"token"); - org.springframework.test.util.ReflectionTestUtils.setField(sources,"readRepository",new com.baiyue.lastleg.appointmentplanning.AppointmentSourceReadRepository(orders,shipping,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.class))); var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); var allocation=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.Context.class); when(allocation.availablePackages()).thenReturn(1L);when(cargo.inspect(any(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source.class))).thenReturn(allocation); @@ -40,18 +52,13 @@ class AppointmentWorkSyncServiceTest { new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",1));}); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo, mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class),mapping); - wireBatches(provider,cargo,mapping); - when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); var rows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(i->new ContainerSource(""+i,"CNT"+i, Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null, List.of(new Requirement("INBOUND:"+i,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(1)).catalog("token"); - verify(orders,times(1)).selectBatchIds(anyCollection()); - verify(external,times(1)).resolveOrders(anyList()); - var reads=(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository)org.springframework.test.util.ReflectionTestUtils.getField(provider,"reads"); - verify(reads,times(1)).sources(argThat(ids->ids.size()==100 && new HashSet<>(ids).size()==100)); - verify(cargo,times(1)).prepareInspection(argThat(values->values.size()==100)); + verify(orders,times(100)).selectBatchIds(anyCollection()); var before=f.store.all().stream().map(f.store::write).toList(); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(2)).catalog("token"); assertEquals(before,f.store.all().stream().map(f.store::write).toList()); @@ -59,27 +66,7 @@ class AppointmentWorkSyncServiceTest { verify(warehouse,times(3)).catalog("token"); f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); verify(warehouse,times(4)).catalog("token"); - verify(orders,times(4)).selectBatchIds(anyCollection()); - verify(reads,times(4)).sources(anyList());verify(cargo,times(4)).prepareInspection(anyList()); - } - - /** 预取提示不能抢先解析后方坏来源,原循环仍拒绝且不能发布扫描完成。 */ - @Test void invalidSourcesRemainOriginalOrderedFailuresInsteadOfBeingIgnoredByPreparation() { - var valid=new ContainerSource("1","CNT",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()))); - var missingId=new ContainerSource(null,"CNT",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,valid.requirements()); - var missingRequirements=new ContainerSource("2","CNT2",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,null); - for(var rows:List.of(java.util.Arrays.asList((ContainerSource)null),List.of(missingId),java.util.Arrays.asList(valid,null),List.of(valid,missingRequirements))) { - var f=new WorkTestFixture(); - when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); - when(f.gateway.openRequirements(anyList())).thenAnswer(c->{ - var hints=c.>getArgument(0); - assertEquals(rows.get(0)==valid?List.of(new RequirementInput(valid,Flow.TRANSFER)):List.of(),hints); - return (RequirementReader)(source,flow)->source.requirements(); - }); - var error=assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,100))); - assertEquals(503,error.getStatusCode().value());assertEquals("已开船来源缺少身份或真实开船依据",error.getReason()); - verify(f.mapper,never()).saveGuard(any()); - } + verify(orders,times(202)).selectBatchIds(anyCollection()); } /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ @@ -129,9 +116,8 @@ class AppointmentWorkSyncServiceTest { var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); var scope=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.ReadScope.class); when(sources.openReadScope()).thenReturn(scope); - when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{var r=sources.canonicalOrders(group);return new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.OrderGroup(r,sources.resolveBatch(r),null);}).toList()); + when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); - wireBatches(provider,cargo,mapping); when(mapping.containerSources("1")).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(1L,"A","1","1","FBA","ONT8",1),new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(2L,"B","2","1","FBA","ONT8",1))); when(sources.canonicalOrders(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList()); when(sources.resolveBatch(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source(r.sourceKey(),r.customerId(),Long.valueOf(r.sourceKey().substring(6)),null,null,"CNT",null,null,r.destinationKey(),"ONT8",1,"v1",true,List.of())).toList()); @@ -144,7 +130,7 @@ class AppointmentWorkSyncServiceTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(snapshot)); var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),refs); var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root)); - when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.guard.setRequirementVersion(1); f.sync.synchronize(new SyncRequest(null,10)); @@ -400,13 +386,4 @@ class AppointmentWorkSyncServiceTest { assertFalse(event.getFormattedMessage().contains("private-source-detail"));assertNull(event.getThrowableProxy()); } finally { logger.detachAppender(appender);appender.stop(); } } - /** 用既有原事实模拟组装回归,百柜来源查询由真实来源服务计数验证。 */ - private static void wireBatches(com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider provider,com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService cargo,com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper mapping) { - var reads=mock(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository.class); - when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); - org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); - when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); - var appointments=(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService)org.springframework.test.util.ReflectionTestUtils.getField(provider,"appointments"); - when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java index e621910b4..bc953d55a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java @@ -38,7 +38,7 @@ final class WorkTestFixture { when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap(); for(var state:c.>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; }); when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope")); when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲")); - when(gateway.openRequirements(anyList())).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); + when(gateway.openRequirements()).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); } AppointmentWorkState state(String container,Type type,Flow flow,String requirement) { var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java new file mode 100644 index 000000000..60ea11f11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.*; +import jakarta.servlet.http.HttpServletResponse; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +class AppointmentWorkDiagnosticFilterTest { + /** 路由模板不保存路径参数或querystring,其他业务入口不建立scope。 */ + @Test void fixedRoutesDoNotRetainBusinessIdsAndUnrelatedEndpointsPassThrough()throws Exception{ + var filter=new AppointmentWorkDiagnosticFilter(); + var request=new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/items/private-id");request.setQueryString("token=private-secret"); + filter.doFilter(request,new MockHttpServletResponse(),(r,s)->{ + assertEquals(Route.DETAIL,AppointmentWorkDiagnostics.currentSnapshot().route()); + assertFalse(AppointmentWorkDiagnostics.currentSnapshot().toString().contains("private-")); + });assertFalse(active()); + filter.doFilter(new MockHttpServletRequest("GET","/other"),new MockHttpServletResponse(),(r,s)->assertFalse(active())); + } + /** 原filter异常原样透传,随后线程作用域清理。 */ + @Test void originalFailureIsNotReplacedAndScopeAlwaysCloses(){ + var error=new IllegalStateException("private-secret");var filter=new AppointmentWorkDiagnosticFilter(); + assertSame(error,assertThrows(IllegalStateException.class,()->filter.doFilter(new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/items"),new MockHttpServletResponse(),(r,s)->{throw error;}))); + assertFalse(active()); + } + /** 响应状态的诊断读取出错也不能阻止finally清理或覆盖原异常。 */ + @Test void brokenResponseStatusObservationCannotMaskBusinessFailure(){ + var response=mock(HttpServletResponse.class);when(response.getStatus()).thenThrow(new AssertionError("response failed")); + var error=new IllegalStateException("original");var filter=new AppointmentWorkDiagnosticFilter(); + assertSame(error,assertThrows(IllegalStateException.class,()->filter.doFilter(new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/summary"),response,(r,s)->{throw error;}))); + assertFalse(active()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java new file mode 100644 index 000000000..468c4f2aa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java @@ -0,0 +1,287 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Actor; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.SyncRequest; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.SyncResult; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.WorkDetail; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkQueryService; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkStore; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkSyncService; +import com.zaxxer.hikari.HikariDataSource; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.session.SqlSessionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.AopTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionSynchronization; +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.mock; + +/** 使用真实Spring注册、Hikari及内存H2验证诊断不改变原事务与连接生命周期。 */ +class AppointmentWorkDiagnosticsInfrastructureTest { + /** 每个场景都必须退出当前线程的诊断作用域,不能污染后续测试或请求。 */ + @AfterEach + void noScopeLeaksFromTheTestThread() { + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isNull(); + } + + /** 解包保持容器Bean自身语义和配置委托;内部target身份不同,但池与关闭行为不变。 */ + @Test + void hikariProxyRemainsInjectableAndTransactionAndMybatisUseTheSameBean() throws Exception { + HikariDataSource original; + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var dataSource=context.getBean(HikariDataSource.class); + original=AopTestUtils.getUltimateTargetObject(dataSource); + assertThat(AopUtils.isCglibProxy(dataSource)).isTrue(); + assertThat(dataSource).isNotSameAs(original); + assertThat(original.unwrap(HikariDataSource.class)).isSameAs(original); + assertThat(dataSource.unwrap(HikariDataSource.class)).isSameAs(dataSource); + assertThat(dataSource.getJdbcUrl()).isEqualTo(original.getJdbcUrl()); + assertThat(dataSource.getUsername()).isEqualTo(original.getUsername()); + assertThat(dataSource.getPassword()).isEqualTo(original.getPassword()); + assertThat(dataSource.getDataSourceProperties()).isSameAs(original.getDataSourceProperties()); + assertThat(dataSource.isWrapperFor(HikariDataSource.class)).isTrue(); + assertThat(context.getBean(ObservingTransactionManager.class).getDataSource()).isSameAs(dataSource); + assertThat(context.getBean(SqlSessionFactory.class).getConfiguration().getEnvironment().getDataSource()).isSameAs(dataSource); + try(Connection connection=dataSource.getConnection()) { + assertThat(connection.getClass().getName()).startsWith("com.zaxxer.hikari.pool.HikariProxyConnection"); + assertThat(dataSource.getHikariPoolMXBean()).isSameAs(original.getHikariPoolMXBean()); + assertThat(original.getHikariPoolMXBean().getActiveConnections()).isEqualTo(1); + } + assertThat(original.getHikariPoolMXBean().getActiveConnections()).isZero(); + assertThat(original.isClosed()).isFalse(); + } + assertThat(original.isClosed()).isTrue(); + } + + /** 自动注册的诊断Advisor必须包住事务提交,Mapper连续调用共用同一事务连接。 */ + @Test + void productionAdvisorStaysActiveThroughCommitAndPreservesTheCommittedWrite() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + var service=context.getBean(QueryProbe.class); + assertThat(AopUtils.isAopProxy(service)).isTrue(); + service.detail("commit",null); + assertThat(probe.bodyScope).isNotNull(); + assertThat(probe.events).containsExactly("body","commit-before","commit-after"); + assertThat(probe.transactionScopes).containsOnly(probe.bodyScope); + assertThat(probe.transactionActive).isTrue(); + assertThat(probe.boundResource).isTrue(); + assertThat(probe.firstSession).isEqualTo(probe.secondSession); + assertThat(probe.bodySnapshot.metrics().get("CONNECTION_ACQUIRE").calls()).isEqualTo(1); + assertThat(probe.bodySnapshot.metrics().get("DB_EXECUTOR_UPDATE:OTHER").calls()).isEqualTo(1); + assertThat(context.getBean(ProbeMapper.class).count()).isEqualTo(1); + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + } + } + + /** 原业务异常不能被诊断替换,回滚完成时仍有同一作用域,结束后清理。 */ + @Test + void originalFailureRollsBackAndEscapesUnchangedAfterDiagnosticCleanup() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + var failure=new IllegalStateException("原业务失败");probe.failure=failure; + assertThat(catchThrowable(()->context.getBean(QueryProbe.class).detail("rollback",null))).isSameAs(failure); + assertThat(probe.events).containsExactly("body","rollback-before","rollback-after"); + assertThat(probe.transactionScopes).containsOnly(probe.bodyScope); + assertThat(context.getBean(ProbeMapper.class).count()).isZero(); + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + } + } + + /** 已有HTTP作用域由查询复用,同步开启子作用域后恢复外层,均在finally中关闭。 */ + @Test + void httpScopeIsReusedAndNestedSyncRestoresItWithoutThreadLocalResidue() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + try(var outer=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) { + String outerId=AppointmentWorkDiagnostics.currentScopeId(); + context.getBean(QueryProbe.class).detail("outer",null); + assertThat(probe.bodyScope).isEqualTo(outerId); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(outerId); + context.getBean(SyncProbe.class).synchronize(new SyncRequest(null,1)); + assertThat(probe.syncScope).isNotNull().isNotEqualTo(outerId); + assertThat(probe.syncParent).isEqualTo(outerId); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(outerId); + assertThat(outer.snapshot().scopeId()).isEqualTo(outerId); + assertThat(outer.snapshot().metrics().get("TRANSACTION_ADVISOR").calls()).isEqualTo(1); + } + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + } + } + + /** 请求外的连接及SQL不创建诊断作用域,也不会被计入之后新开的请求。 */ + @Test + void outOfScopeDatabaseWorkDoesNotCreateOrPolluteTheNextScope() throws Exception { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + context.getBean(ProbeMapper.class).count(); + try(var connection=context.getBean(DataSource.class).getConnection()) { assertThat(connection.isClosed()).isFalse(); } + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + try(var scope=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) { + assertThat(scope.snapshot().metrics()).isEmpty(); + context.getBean(ProbeMapper.class).count(); + assertThat(scope.snapshot().metrics()).isNotEmpty(); + } + } + } + + /** 真正的afterCompletion分别记录提交和回滚,不能把方法返回时间当作事务完成时间。 */ + @Test + void realTransactionCompletionRecordsStatusAndOffsetBeforeTheHttpScopeCloses() { + for(boolean rollback:List.of(false,true)) { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + var original=new IllegalStateException("真实事务回滚"); + if(rollback)probe.failure=original; + try(var scope=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) { + var error=catchThrowable(()->context.getBean(QueryProbe.class).detail("completion",null)); + if(rollback)assertThat(error).isSameAs(original);else assertThat(error).isNull(); + assertThat(probe.bodySnapshot.transactionObserved()).isTrue(); + assertThat(probe.bodySnapshot.completionStatus()).isEqualTo(-1); + assertThat(probe.bodySnapshot.completionOffsetNanos()).isEqualTo(-1); + var completed=scope.snapshot(); + assertThat(completed.transactionObserved()).isTrue(); + assertThat(completed.completionStatus()).isEqualTo(rollback + ?TransactionSynchronization.STATUS_ROLLED_BACK:TransactionSynchronization.STATUS_COMMITTED); + assertThat(completed.completionOffsetNanos()).isBetween(0L,completed.elapsedNanos()); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(completed.scopeId()); + } + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + assertThat(context.getBean(ProbeMapper.class).count()).isEqualTo(rollback?0:1); + } + } + } + + interface ProbeMapper { + /** 测试事务只向本地内存表写一行,验证原事务结果。 */ + @Insert("INSERT INTO diagnostic_probe(id) VALUES(1)") + int insert(); + /** 直接执行两次查询,确认实际事务复用同一H2会话。 */ + @Select("SELECT SESSION_ID()") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + long sessionId(); + /** 在事务外核对提交或回滚后的真实行数。 */ + @Select("SELECT COUNT(*) FROM diagnostic_probe") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + int count(); + } + + static final class Probe { + final List events=new ArrayList<>(); + final List transactionScopes=new ArrayList<>(); + String bodyScope; + String syncScope; + String syncParent; + AppointmentWorkDiagnostics.Snapshot bodySnapshot; + long firstSession; + long secondSession; + boolean transactionActive; + boolean boundResource; + RuntimeException failure; + } + + public static class QueryProbe extends AppointmentWorkQueryService { + private final ProbeMapper mapper; + private final Probe probe; + private final DataSource dataSource; + /** 使用生产查询服务类型及原方法签名触发生产pointcut,不新增诊断匹配规则。 */ + QueryProbe(ProbeMapper mapper,Probe probe,DataSource dataSource) { + super(mock(AppointmentWorkStore.class),mock(AppointmentWorkFactGateway.class)); + this.mapper=mapper;this.probe=probe;this.dataSource=dataSource; + } + /** 只替换测试业务体,真实Spring事务、连接获取和生产诊断链不替换。 */ + @Override @Transactional(readOnly=false) + public WorkDetail detail(String key,Actor actor) { + AppointmentWorkDiagnostics.observeTransaction(); + probe.bodyScope=AppointmentWorkDiagnostics.currentScopeId();probe.events.add("body"); + probe.transactionActive=TransactionSynchronizationManager.isActualTransactionActive(); + probe.boundResource=TransactionSynchronizationManager.hasResource(dataSource); + mapper.insert();probe.firstSession=mapper.sessionId();probe.secondSession=mapper.sessionId(); + probe.bodySnapshot=AppointmentWorkDiagnostics.currentSnapshot(); + if(probe.failure!=null)throw probe.failure; + return null; + } + } + + public static class SyncProbe extends AppointmentWorkSyncService { + private final Probe probe; + /** 使用原同步服务类型与方法,使生产Advisor执行真正的嵌套作用域规则。 */ + SyncProbe(Probe probe) { super(mock(AppointmentWorkStore.class),mock(AppointmentWorkFactGateway.class));this.probe=probe; } + /** 同步探针不修改业务数据,仅记录事务内当前作用域。 */ + @Override @Transactional + public SyncResult synchronize(SyncRequest request) { + probe.syncScope=AppointmentWorkDiagnostics.currentScopeId(); + probe.syncParent=AppointmentWorkDiagnostics.currentSnapshot().parentScopeId(); + return new SyncResult(0,0,null,true); + } + } + + static class ObservingTransactionManager extends DataSourceTransactionManager { + private final Probe probe; + /** 在原事务管理器提交及回滚两侧观察诊断作用域,不替换数据库动作。 */ + ObservingTransactionManager(DataSource dataSource,Probe probe) { super(dataSource);this.probe=probe; } + /** 诊断必须覆盖真实提交前后,不能只包住业务方法体。 */ + @Override protected void doCommit(DefaultTransactionStatus status) { + observe("commit-before");super.doCommit(status);observe("commit-after"); + } + /** 原异常回滚路径同样保持作用域到数据库回滚完成。 */ + @Override protected void doRollback(DefaultTransactionStatus status) { + observe("rollback-before");super.doRollback(status);observe("rollback-after"); + } + /** 只记录执行顺序和内部scope标识,不收集凭据、SQL参数或业务资料。 */ + private void observe(String phase) { probe.events.add(phase);probe.transactionScopes.add(AppointmentWorkDiagnostics.currentScopeId()); } + } + + @Configuration(proxyBeanMethods=false) + @EnableTransactionManagement(proxyTargetClass=true) + @Import(AppointmentWorkDiagnosticsConfiguration.class) + static class Config { + /** 使用独立内存H2,池大小有界且由Spring负责关闭。 */ + @Bean(destroyMethod="close") HikariDataSource dataSource() { + var source=new HikariDataSource();source.setJdbcUrl("jdbc:h2:mem:diagnostic-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + source.setUsername("sa");source.setPassword("");source.setMaximumPoolSize(2);source.setMinimumIdle(0);return source; + } + /** 本地测试共享观测记录,不影响生产诊断实现。 */ + @Bean Probe probe() { return new Probe(); } + /** 将生产后处理后的同一个DataSource交给真实Spring事务管理器。 */ + @Bean ObservingTransactionManager transactionManager(DataSource dataSource,Probe probe) { return new ObservingTransactionManager(dataSource,probe); } + /** 使用生产注册的MyBatis插件与同一个DataSource,禁止另造未代理工厂。 */ + @Bean SqlSessionFactory sqlSessionFactory(DataSource dataSource,List interceptors) throws Exception { + new JdbcTemplate(dataSource).execute("CREATE TABLE diagnostic_probe(id INT PRIMARY KEY)"); + var configuration=new MybatisConfiguration();configuration.addMapper(ProbeMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(configuration);factory.setDataSource(dataSource); + factory.setPlugins(interceptors.toArray(Interceptor[]::new));return factory.getObject(); + } + /** 模板按原事务绑定复用连接,不直接创建业务SQL连接。 */ + @Bean SqlSessionTemplate template(SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } + /** 真实Mapper由SqlSessionTemplate生成。 */ + @Bean ProbeMapper mapper(SqlSessionTemplate template) { return template.getMapper(ProbeMapper.class); } + /** 原查询类型交给Spring自动代理,测试不手动构造ProxyFactory。 */ + @Bean QueryProbe queryProbe(ProbeMapper mapper,Probe probe,DataSource dataSource) { return new QueryProbe(mapper,probe,dataSource); } + /** 原同步类型交给同一生产Advisor自动匹配。 */ + @Bean SyncProbe syncProbe(Probe probe) { return new SyncProbe(probe); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java new file mode 100644 index 000000000..278396684 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.*; +import org.slf4j.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +class AppointmentWorkDiagnosticsTest { + /** 每项隔离测试都必须释放线程上下文。 */ + @AfterEach void noLeak(){assertFalse(active());MDC.clear();} + /** 同桶嵌套按完成次序累加,首尾时刻仍覆盖外层且self不重复相加。 */ + @Test void nestedSameBucketTracksInclusiveSelfAndTrueOuterOffsets(){ + var clock=new AtomicLong(); + try(var scope=new Scope(Origin.QUERY,Route.INTERNAL,clock::get)){ + clock.set(10);var outer=span(Stage.QUERY_FACTS);clock.set(20);var inner=span(Stage.QUERY_FACTS); + clock.set(50);inner.close();clock.set(100);outer.close();outer.close(); + var metric=scope.snapshot().metrics().get("QUERY_FACTS"); + assertEquals(2,metric.calls());assertEquals(120,metric.inclusiveNanos());assertEquals(90,metric.selfNanos()); + assertEquals(10,metric.firstOffsetNanos());assertEquals(100,metric.lastOffsetNanos()); + } + } + /** 独立同步scope仅以父标识关联,父self不假称已扣除另一作用域。 */ + @Test void independentScopeRestoresParentAndDoesNotSubtractItsIntervals(){ + var clock=new AtomicLong();MDC.put("traceId","safe-trace"); + try(var outer=new Scope(Origin.HTTP,Route.ITEMS,clock::get)){ + var span=span(Stage.QUERY_FACTS);clock.set(10); + try(var child=new Scope(Origin.SYNC,Route.INTERNAL,clock::get)){ + assertEquals(outer.snapshot().scopeId(),child.snapshot().parentScopeId()); + try(var nested=span(Stage.SYNC_BODY)){clock.set(50);} + } + assertEquals(outer.snapshot().scopeId(),currentScopeId());clock.set(100);span.close(); + assertEquals(100,outer.snapshot().metrics().get("QUERY_FACTS").selfNanos()); + } + assertEquals("safe-trace",MDC.get("traceId")); + } + /** 快速异常即使被上层映射为HTTP200,仍输出一次失败摘要且不含原异常文本。 */ + @Test void swallowedFastFailureStillMarksScopeAndPreservesOriginalException(){ + try(var log=new LogCapture();var scope=open(Origin.HTTP,Route.COMMAND)){ + var error=new IllegalStateException("customer-secret-business-id"); + assertSame(error,assertThrows(IllegalStateException.class,()->measure(Stage.QUERY_FACTS,()->{throw error;}))); + scope.status(200);assertTrue(scope.snapshot().failed());scope.close(); + assertEquals(1,log.appender.list.size());var text=log.appender.list.get(0).getFormattedMessage(); + assertTrue(text.contains("failed=true"));assertFalse(text.contains("customer-secret"));assertNull(log.appender.list.get(0).getThrowableProxy()); + } + } + /** 诊断日志设施自身抛错时清理仍完成,不向业务调用传播。 */ + @Test void loggingFailureDoesNotEscapeOrLeakScope(){ + var logger=(Logger)LoggerFactory.getLogger(AppointmentWorkDiagnostics.class); + var failing=new AppenderBase(){ + /** 模拟日志输出设施故障,不借业务异常路径。 */ + @Override protected void append(ILoggingEvent event){throw new AssertionError("logger failed");} + };failing.start();logger.addAppender(failing); + try{var scope=open(Origin.QUERY,Route.INTERNAL);scope.failed();assertDoesNotThrow(scope::close);assertFalse(active());} + finally{logger.detachAppender(failing);failing.stop();} + } + /** 快速成功无摘要,返回对象和调用次数均保持原样。 */ + @Test void successfulFastOperationIsTransparentAndOutOfScopeIsNoOp(){ + var value=new Object();var calls=new AtomicLong(); + assertSame(value,measure(Stage.QUERY_FACTS,()->{calls.incrementAndGet();return value;}));assertFalse(active()); + try(var log=new LogCapture();var scope=new Scope(Origin.QUERY,Route.INTERNAL,()->0)){ + assertSame(value,measure(Stage.QUERY_FACTS,()->{calls.incrementAndGet();return value;}));scope.close();assertTrue(log.appender.list.isEmpty()); + }assertEquals(2,calls.get()); + } + /** 非诊断格式的MDC值不进入摘要,原MDC内容不会被修改。 */ + @Test void arbitraryMdcTextIsExcludedWithoutChangingCallerContext(){ + MDC.put("requestId","customer password\nsecret");MDC.put("traceId","a".repeat(32)); + try(var scope=open(Origin.QUERY,Route.INTERNAL)){ + assertNull(scope.snapshot().requestId());assertEquals("a".repeat(32),scope.snapshot().traceId()); + }assertEquals("customer password\nsecret",MDC.get("requestId")); + } + static final class LogCapture implements AutoCloseable{ + final Logger logger=(Logger)LoggerFactory.getLogger(AppointmentWorkDiagnostics.class); + final ListAppender appender=new ListAppender<>(); + /** 只捕获本诊断logger,不改变全局日志配置。 */ + LogCapture(){appender.start();logger.addAppender(appender);} + /** 释放本测试附加器。 */ + @Override public void close(){logger.detachAppender(appender);appender.stop();} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java new file mode 100644 index 000000000..a8976c803 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.*; +import org.apache.ibatis.builder.StaticSqlSource; +import org.apache.ibatis.session.*; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +class AppointmentWorkMyBatisDiagnosticsTest { + /** 插件不得重试、替换原异常或把动态语句标识与参数带入诊断。 */ + @Test void executorFailureIsTransparentAndBucketContextDoesNotLeak()throws Exception{ + var target=mock(Executor.class);var proxy=(Executor)new AppointmentWorkMyBatisDiagnostics().plugin(target); + var config=new Configuration(); + var statement=new MappedStatement.Builder(config,"private-customer-secret",new StaticSqlSource(config,"SELECT private_payload"),SqlCommandType.SELECT).build(); + var failure=new IllegalStateException("private-business-failure"); + when(target.query(statement,"private-token",RowBounds.DEFAULT,null)).thenThrow(failure); + try(var scope=open(Origin.QUERY,Route.INTERNAL)){ + assertSame(failure,assertThrows(IllegalStateException.class,()->proxy.query(statement,"private-token",RowBounds.DEFAULT,null))); + assertEquals(1,scope.snapshot().metrics().get("DB_EXECUTOR_QUERY:OTHER").failures()); + assertFalse(scope.snapshot().toString().contains("private-"));verify(target).query(statement,"private-token",RowBounds.DEFAULT,null); + } + var next=new MappedStatement.Builder(config,"com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard",new StaticSqlSource(config,"SELECT 1"),SqlCommandType.SELECT).build(); + var expected=List.of(new Object());when(target.query(next,null,RowBounds.DEFAULT,null)).thenReturn(expected); + try(var scope=open(Origin.QUERY,Route.INTERNAL)){ + assertSame(expected,proxy.query(next,null,RowBounds.DEFAULT,null)); + assertEquals(1,scope.snapshot().metrics().get("DB_EXECUTOR_QUERY:GUARD_READ").calls());assertFalse(scope.snapshot().metrics().containsKey("DB_EXECUTOR_QUERY:OTHER")); + }assertFalse(active());verify(target).query(next,null,RowBounds.DEFAULT,null);verifyNoMoreInteractions(target); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 9471a3521..48402799f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -259,12 +259,6 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;} @Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();} @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} - /** 新增只读原料端口仍接实际 Mapper;本测试未涉及船节点查询。 */ - @Bean AppointmentSourceReadRepository sourceReads(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,AppointmentCoverageMapper facts){return new AppointmentSourceReadRepository(orders,mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class),facts);} - /** 核实原料沿真实持久 Mapper 读取,不替代事务断言。 */ - @Bean AppointmentCargoReadRepository cargoReads(AppointmentCargoVerificationMapper mapper){return new AppointmentCargoReadRepository(mapper);} - /** 历史预约原料沿本测试真实主单、明细及查询 Mapper 读取。 */ - @Bean com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads(InboundFbaAppointmentQueryMapper query,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items){return new com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository(mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class),query,appointments,items);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java deleted file mode 100644 index 36e69f704..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.baiyue.lastleg.inboundplan.work; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; -import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper.RequirementSource; -import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef; -import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.ibatis.executor.Executor; -import org.apache.ibatis.mapping.MappedStatement; -import org.apache.ibatis.plugin.Interceptor; -import org.apache.ibatis.plugin.Intercepts; -import org.apache.ibatis.plugin.Invocation; -import org.apache.ibatis.plugin.Signature; -import org.apache.ibatis.mapping.Environment; -import org.apache.ibatis.session.ResultHandler; -import org.apache.ibatis.session.RowBounds; -import org.apache.ibatis.session.SqlSession; -import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; -import org.h2.jdbcx.JdbcDataSource; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.ArrayList; -import java.util.Collections; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class AppointmentRequirementReadRepositoryTest { - /** 关联依据是原客户、来源及目的仓,不能因失活、取消或响应明细变空而消失。 */ - @Test - void historyKeepsInactiveAndReplacedAssociationsWithoutCrossCustomerOrDestinationLeak() throws Exception { - try (var fixture=new Fixture()) { - fixture.appointment(10,"CANCELLED",null,null); - fixture.appointment(20,"CANCELLED","[\"AP-30\"]","[\"FBA_APPOINTMENT:30\"]"); - fixture.appointment(30,"PENDING",null,null); - fixture.item(101,10,"A","ORDER:1","ABE4",false); - fixture.item(201,20,"A","ORDER:1","ABE4",false); - fixture.item(301,30,"A","ORDER:1","ABE4",true); - fixture.item(302,30,"B","ORDER:2","ABE4",true); - fixture.item(401,40,"B","ORDER:1","ABE4",true); - fixture.item(501,50,"A","ORDER:1","ONT8",true); - var refs=List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), - new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), - new RequirementSource(1,"B","ORDER:2","FBA:ABE4"), - new RequirementSource(2,"B","ORDER:1","FBA:ABE4")); - var history=fixture.repository.history(refs); - assertThat(history).filteredOn(row->row.groupOrdinal()==0).extracting(row->row.appointmentId()).containsExactly(30L,20L,10L); - assertThat(history).filteredOn(row->row.groupOrdinal()==1).extracting(row->row.appointmentId()).containsExactly(30L); - assertThat(history).filteredOn(row->row.groupOrdinal()==2).extracting(row->row.appointmentId()).containsExactly(40L); - assertThat(fixture.repository.items(List.of(10L,20L,30L))).hasSize(4) - .filteredOn(row->!Boolean.TRUE.equals(row.getActive())).extracting(InboundFbaAppointmentItem::getId).containsExactly(101L,201L); - assertThat(fixture.repository.appointments(List.of(20L))).singleElement().satisfies(row->{ - assertThat(row.getReplacementNosJson()).isEqualTo("[\"AP-30\"]"); - assertThat(row.getReplacementKeysJson()).isEqualTo("[\"FBA_APPOINTMENT:30\"]"); - }); - var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( - List.of(new SourceRef("A","ORDER:1","FBA:ABE4")),List.of(new SourceRef("B","ORDER:2","FBA:ABE4")))); - assertThat(prepared.apply(0)).extracting(row->row.id()).containsExactly(10L,20L,30L); - assertThat(prepared.apply(0)).filteredOn(row->row.id()==10).singleElement().satisfies(row->{ - assertThat(row.appointmentStatus()).isEqualTo("CANCELLED"); - assertThat(row.items()).isEmpty(); - }); - assertThat(prepared.apply(0)).filteredOn(row->row.id()==20).singleElement().satisfies(row->{ - assertThat(row.items()).hasSize(1); - assertThat(row.replacementBatchKeys()).containsExactly("FBA_APPOINTMENT:30"); - }); - assertThat(prepared.apply(1)).singleElement().satisfies(row->{ - assertThat(row.id()).isEqualTo(30L); - assertThat(row.items()).extracting(item->item.customerId()).containsExactly("A","B"); - }); - } - } - - /** SQL 对每组各保留第1001条哨兵,不能以所有组总数截断历史。 */ - @Test - void windowLimitIsPerRequirementGroupAndDuplicateReferencesDoNotUseUpTheLimit() throws Exception { - try (var fixture=new Fixture()) { - for(int i=1;i<=1002;i++)fixture.item(i,i,"A","ORDER:1","ABE4",i%2==0); - for(int i=1;i<=1000;i++)fixture.item(10000+i,20000+i,"B","ORDER:2","ABE4",true); - var rows=fixture.repository.history(List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), - new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),new RequirementSource(1,"B","ORDER:2","FBA:ABE4"))); - assertThat(rows).hasSize(2001); - assertThat(rows).filteredOn(row->row.groupOrdinal()==0).hasSize(1001) - .extracting(row->row.appointmentId()).startsWith(1002L).endsWith(2L).doesNotContain(1L); - assertThat(rows).filteredOn(row->row.groupOrdinal()==1).hasSize(1000); - } - } - - /** 空输入直接结束,不向数据库提交无效IN列表。 */ - @Test - void emptyInputsReturnNoRows() throws Exception { - try (var fixture=new Fixture()) { - assertThat(fixture.repository.history(List.of())).isEmpty(); - assertThat(fixture.repository.appointments(List.of())).isEmpty(); - assertThat(fixture.repository.items(List.of())).isEmpty(); - assertThat(fixture.repository.sources(List.of())).isEmpty(); - } - } - - /** 100个来源和200个预约的分块不会退化成逐组SQL,预备结果重读不再访问数据库。 */ - @Test - void manyGroupsUseBoundedRealQueriesAndPreparedResponsesDoNotReadAgain() throws Exception { - try(var fixture=new Fixture()) { - var groups=new ArrayList>(); - for(int i=1;i<=201;i++) { - fixture.appointment(i,"PENDING",null,null); - fixture.item(i,i,"A","ORDER:"+i,"ABE4",true); - groups.add(List.of(new SourceRef("A","ORDER:"+i,"FBA:ABE4"))); - } - var prepared=fixture.service.prepareRelatedBatchSnapshots(groups); - for(int i=0;i<201;i++)assertThat(prepared.apply(i)).extracting(row->row.id()).containsExactly((long)i+1); - assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(3); - assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(2); - assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(2); - int before=fixture.counter.statements.size(); - assertThat(prepared.apply(0)).hasSize(1); - assertThat(fixture.counter.statements).hasSize(before); - } - } - - /** 同一需求的来源跨SQL分块时,相同预约只归属一次,空来源组仍独立。 */ - @Test - void oneGroupCrossingTheSourceChunkBoundaryDeduplicatesItsOriginalAppointment() throws Exception { - try(var fixture=new Fixture()) { - fixture.appointment(30,"PENDING",null,null); - fixture.item(301,30,"A","ORDER:1","ABE4",true); - fixture.item(302,30,"A","ORDER:2","ABE4",true); - var first=new ArrayList(); - for(int i=0;i<99;i++)first.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); - var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(first,List.of( - new SourceRef("A","ORDER:1","FBA:ABE4"),new SourceRef("A","ORDER:2","FBA:ABE4")),List.of())); - assertThat(prepared.apply(0)).isEmpty(); - assertThat(prepared.apply(1)).singleElement().satisfies(row->{ - assertThat(row.id()).isEqualTo(30L);assertThat(row.items()).hasSize(2); - }); - assertThat(prepared.apply(2)).isEmpty(); - assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); - assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(1); - } - } - - /** 每组1000条允许,1001条和101个原来源仅在访问本组时拒绝,不拖垮其他组。 */ - @Test - void groupLimitsAreCheckedAfterCompleteMergeAndErrorsStayWithTheirOriginalGroup() throws Exception { - try(var fixture=new Fixture()) { - for(int i=1;i<=1000;i++) { - fixture.appointment(i,"PENDING",null,null); - fixture.item(i,i,"A","ORDER:1","ABE4",true); - } - for(int i=1;i<=1001;i++)fixture.item(10000+i,20000+i,"B",i<=600?"ORDER:2":"ORDER:3","ABE4",false); - var validGroup=new ArrayList(); - validGroup.add(new SourceRef("A","ORDER:1","FBA:ABE4")); - for(int i=0;i<98;i++)validGroup.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); - // 超限组的600条与401条来自两个SQL块,必须合并后才能准确拒绝。 - var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( - validGroup, - List.of(new SourceRef("B","ORDER:2","FBA:ABE4"),new SourceRef("B","ORDER:3","FBA:ABE4")), - Collections.nCopies(101,new SourceRef("A","ORDER:1","FBA:ABE4")))); - assertThat(prepared.apply(0)).hasSize(1000); - assertThatThrownBy(()->prepared.apply(1)).isInstanceOfSatisfying(ResponseStatusException.class, - error->assertThat(error.getStatusCode().value()).isEqualTo(503)); - assertThatThrownBy(()->prepared.apply(2)).isInstanceOfSatisfying(ResponseStatusException.class, - error->assertThat(error.getStatusCode().value()).isEqualTo(400)); - assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(5); - assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(5); - assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); - } - } - - @Intercepts(@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})) - private static final class QueryCounter implements Interceptor { - final List statements=new ArrayList<>(); - /** 记录真实Mapper执行入口,调用仍继续进入原SQL执行器。 */ - @Override public Object intercept(Invocation invocation) throws Throwable { - statements.add(((MappedStatement)invocation.getArgs()[0]).getId());return invocation.proceed(); - } - /** 按具体Mapper语句核对批量读取次数。 */ - long count(String suffix) { return statements.stream().filter(id->id.endsWith(suffix)).count(); } - } - - private static final class Fixture implements AutoCloseable { - final JdbcTemplate jdbc; - final SqlSession session; - final AppointmentRequirementReadRepository repository; - final InboundFbaAppointmentService service; - final QueryCounter counter=new QueryCounter(); - - /** 只在独立H2内使用基线表,并装配真实生产Mapper。 */ - Fixture() throws Exception { - var datasource=new JdbcDataSource(); - datasource.setURL("jdbc:h2:mem:requirement-history-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); - jdbc=new JdbcTemplate(datasource); - String baseline; - try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) { - baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8); - } - for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { - int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" ("); - assertThat(start).isGreaterThanOrEqualTo(0); - jdbc.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";")); - } - var config=new MybatisConfiguration(); - config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),datasource)); - config.setMapUnderscoreToCamelCase(true); - config.addInterceptor(counter); - config.addMapper(AppointmentWorkSourceMapper.class); - config.addMapper(InboundFbaAppointmentQueryMapper.class); - config.addMapper(InboundFbaAppointmentMapper.class); - config.addMapper(InboundFbaAppointmentItemMapper.class); - session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(); - repository=new AppointmentRequirementReadRepository(session.getMapper(AppointmentWorkSourceMapper.class), - session.getMapper(InboundFbaAppointmentQueryMapper.class),session.getMapper(InboundFbaAppointmentMapper.class), - session.getMapper(InboundFbaAppointmentItemMapper.class)); - service=new InboundFbaAppointmentService(); - ReflectionTestUtils.setField(service,"requirementReads",repository); - ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); - } - - /** 建立真实预约头,取消和替代分别保留原状态与去向。 */ - void appointment(long id,String status,String replacementNos,String replacementKeys) { - jdbc.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by,appointment_status,replacement_nos_json,replacement_keys_json) VALUES(?,?,'WH','ABE4','TEST',?,?,?)", - id,"AP-"+id,status,replacementNos,replacementKeys); - } - - /** 建立原来源历史,不以活动标记代替来源归属。 */ - void item(long id,long appointment,String customer,String source,String destination,boolean active) { - jdbc.update("INSERT INTO ll_inbound_fba_appointment_item(id,appointment_id,customer_id,source_key,fba_warehouse_code,active,package_count) VALUES(?,?,?,?,?,?,1)", - id,appointment,customer,source,destination,active?1:0); - } - - /** 测试结束关闭本地会话,不接触任何共享数据库。 */ - @Override public void close() { session.close(); } - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java index 85c9e8b36..acb30573e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java @@ -15,6 +15,16 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentWorkDeliveryProviderTest { + /** 在真实provider上启用计时,固定批量阶段各执行一次,后续原业务断言继续验证结果。 */ + private static List diagnosticRead(AppointmentWorkDeliveryProvider provider,List requests){ + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)){ + var result=provider.read(requests); + for(var stage:List.of("DELIVERY_SNAPSHOTS","DELIVERY_ROWS","DELIVERY_RECEIVING","DELIVERY_IMPACTS","DELIVERY_ASSEMBLY"))assertEquals(1,scope.snapshot().metrics().get(stage).calls(),stage); + return result; + } + } @Test void confirmedCustomerIndependentFailedPlanAndUnplannedSiblingRemainSeparate() { String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles"; var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var receiving=receiving(); @@ -33,7 +43,7 @@ class AppointmentWorkDeliveryProviderTest { when(commitments.impactTokens(anyList())).thenAnswer(call->{List keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));}); var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving); var requests=List.of("A","B","D").stream().map(c->new FactRequest("W"+c,Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:"+c,key,null,List.of(key),List.of(),List.of(new SourceReference(c,"ORDER:1","FBA:ONT8")))).toList(); - var facts=provider.read(requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2); + var facts=diagnosticRead(provider,requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2); assertEquals(confirmedAt.toLocalDate(),aFact.confirmed().date());assertNull(aFact.target());assertEquals(PlanStatus.CONFIRMED,aFact.planStatus());assertTrue(aFact.blockReasons().isEmpty()); assertEquals(failedDate,bFact.target().date());assertNull(bFact.confirmed());assertEquals(PlanStatus.TARGET_SET,bFact.planStatus());assertEquals("沿原批次重新预约",bFact.action().label()); assertEquals(PlanStatus.UNPLANNED,dFact.planStatus());assertNull(dFact.target());assertNull(dFact.confirmed());assertTrue(dFact.blockReasons().isEmpty()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index a0eb47036..3d5c93c7b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -11,32 +11,11 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; class AppointmentWorkRequirementProviderTest { - /** 一组前置未就绪只保留自己的原义务,同页其他客户的需求与独立读取一致。 */ - @Test void pagePreparationKeepsUnresolvedGroupsLocalAndMatchesIndependentReads() { - var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); - var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); - when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of(new AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",10));}); - when(sources.canonicalOrders(anyList())).thenAnswer(c->c.>getArgument(0).stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList()); - when(sources.resolveBatch(anyList())).thenAnswer(c->{ - var ref=c.>getArgument(0).get(0); - if(ref.sourceKey().equals("ORDER:1")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"货物尚无已开船或后续实际船节点"); - return List.of(new AppointmentSourceService.Source(ref.sourceKey(),ref.customerId(),2L,"IN2",null,"CNT2","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())); - }); - var available=mock(AppointmentCargoVerificationService.Context.class);when(available.availablePackages()).thenReturn(10L);when(cargo.inspect(any(AppointmentSourceService.Source.class))).thenReturn(available); - var containers=List.of("1","2").stream().map(id->new ContainerSource(id,"CNT"+id,null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:"+id,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); - var page=provider.openRequirements(containers.stream().map(c->new RequirementInput(c,Flow.TRANSFER)).toList()); - var first=page.read(containers.get(0),Flow.TRANSFER);var second=page.read(containers.get(1),Flow.TRANSFER); - assertEquals(List.of(new SourceReference("C1","ORDER:1",null)),first.get(0).sources()); - assertEquals(List.of(new SourceReference("C2","ORDER:2","FBA:ONT8")),second.get(0).sources()); - assertEquals(first,provider.openRequirements(List.of()).read(containers.get(0),Flow.TRANSFER)); - assertEquals(second,provider.openRequirements(List.of()).read(containers.get(1),Flow.TRANSFER)); - } /** 取消历史没有活动货物时退役旧事项,仍按权威分配保留新批次和未分配余量。 */ @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -54,29 +33,29 @@ class AppointmentWorkRequirementProviderTest { when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2"); when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current)); - var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); when(allocation.availablePackages()).thenReturn(20L); - actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled)); when(allocation.availablePackages()).thenReturn(60L); - actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); // 非取消的空批次仍属缺失事实,不能静默跳过。 when(cancelled.appointmentStatus()).thenReturn("PENDING"); - assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)).getStatusCode().value()); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value()); } @Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -93,87 +72,73 @@ class AppointmentWorkRequirementProviderTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); for(var status:List.of("CANCELLED","FAILED")) { when(batch.appointmentStatus()).thenReturn(status); - var result=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var result=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources()); assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1")); } when(allocation.availablePackages()).thenReturn(20L); - assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2")); - assertEquals(List.of("INBOUND:1"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); } @Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",4))); var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null)); when(sources.canonicalOrders(anyList())).thenReturn(refs); when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")); - var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey()); assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources()); verifyNoInteractions(cargo,appointments); doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)); } @Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10))); var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4); when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch)); - var manifest=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var manifest=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList()); assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey()); when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4))); - var restored=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); verifyNoInteractions(sources,cargo,appointments); } @Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() { var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement)); - assertEquals(List.of(requirement),provider.openRequirements(java.util.List.of()).read(source,Flow.DIRECT)); + assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT)); verifyNoInteractions(sources,cargo,appointments,mapping); } /** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */ @Test void missingLastlegSourcesDoNotOpenDirectoryScope() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=provider(sources,cargo,appointments,mapping); + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); - var reader=provider.openRequirements(java.util.List.of());verifyNoInteractions(sources,cargo,appointments,mapping); + var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments); } - /** 旧业务场景继续模拟相同原事实,查询形状由真实批量集成回归单独验证。 */ - private static AppointmentWorkRequirementProvider provider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper mapping) { - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); - var reads=mock(AppointmentRequirementReadRepository.class); - when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); - org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); - when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); - when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); - return provider; - } /** 保留原来源模拟,显式接入本次读取作用域。 */ private static void scoped(AppointmentSourceService sources) { when(sources.openReadScope()).thenAnswer(call->{ var scope=mock(AppointmentSourceService.ReadScope.class); - when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{ - var refs=sources.canonicalOrders(group); - try { return new AppointmentSourceService.OrderGroup(refs,sources.resolveBatch(refs),null); } - catch(RuntimeException error) { return new AppointmentSourceService.OrderGroup(refs,List.of(),error); } - }).toList()); + when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); return scope; }); }