diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 01958f455..438bcc465 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -40,8 +40,14 @@ public class GlobalExceptionHandler { @Autowired(required = false) private MeterRegistry meterRegistry; + /** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */ @ExceptionHandler(BizException.class) public ResponseEntity> handleBusiness(BizException exception, HttpServletRequest request) { + if (exception.getResultCode().httpStatus().is5xxServerError()) { + log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}", + request.getMethod(), request.getRequestURI(), exception.getResultCode().code(), + CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception); + } return failure(exception.getResultCode(), exception.getMessage(), request); } diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index c1283467e..c2cef199c 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.core.MethodParameter; import org.springframework.validation.BeanPropertyBindingResult; @@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().requestId()).isEqualTo("r1"); } + /** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */ + @Test + @ExtendWith(OutputCaptureExtension.class) + void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts"); + request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request"); + request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace"); + request.setQueryString("keyword=private-query"); + request.addHeader("X-Internal-Service-Token", "private-service-token"); + BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用"); + exception.initCause(new IllegalStateException("缺少尾程来源")); + + var response = handler.handleBusiness(exception, request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo("S0503"); + assertThat(response.getBody().message()).isEqualTo("统计暂不可用"); + assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts", + "S0503", "status-count-request", "status-count-trace", "缺少尾程来源") + .doesNotContain("private-query", "private-service-token"); + } + + /** 预期的业务冲突不应升级为系统错误日志。 */ + @Test + @ExtendWith(OutputCaptureExtension.class) + void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) { + handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"), + new MockHttpServletRequest()); + assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict"); + } + @Test void invalidQueryParameterTypeMapsToBadRequest() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index 418650e34..1e6a102dc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -24,7 +24,7 @@ public class WorkflowTabService { var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); // 确认页签复用已推送费用版本。 for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) { - if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); + if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源"); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); } var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); @@ -32,19 +32,24 @@ public class WorkflowTabService { if(!sources.isEmpty()) { try { var response=lastleg.read(token,new WorkflowFacts.Request(sources)); - if(response==null || !response.successful() || response.data()==null) throw unavailable(); + if(response==null || !response.successful() || response.data()==null) throw unavailable( + "尾程事实响应无效 code="+(response==null?null:response.code()) + +" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId())); var allowed=new HashSet<>(sources); for(var fact:response.data()) { - if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable(); + if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源"); overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded())); } - if(overseas.size()!=sources.size()) throw unavailable(); - } catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); } + if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size() + +" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList()); + } catch(BizException e) { throw e; } catch(Exception e) { + throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e); + } } // SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。 for(var fact:facts) { var inbound=expected.get(fact.inboundId()); - if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable(); + if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId()); var o=overseas.get(fact.tenantId()+":"+fact.inboundId()); for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key)); } @@ -77,5 +82,9 @@ public class WorkflowTabService { return keys; } /** 下游缺失或归属异常不能生成部分数量。 */ - private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); } + private static BizException unavailable(String reason) { + // 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。 + return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试") + .initCause(new IllegalStateException(reason)); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index cf264bcf4..ca565dcbe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -25,12 +25,15 @@ class WorkflowBoundaryTest { inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); when(repository.facts(List.of(1L))).thenReturn(List.of(fact())); when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of())); - assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); + assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class) + .hasMessage("订单业务页签事实暂不可用,请稍后重试") + .hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]"); when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( new WorkflowFacts.Overseas(22,1,true,true,false,true)))); assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); - when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable")); - assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); + var failure=new IllegalStateException("unavailable"); + when(downstream.read(any(),any())).thenThrow(failure); + assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure); } /** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java index ef5448da4..c253d5ac6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java @@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress; import org.apache.ibatis.annotations.*; import java.util.List; -/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */ +/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */ @Mapper public interface CustomerOrderProgressMapper { - /** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */ + /** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */ @Select("""