This commit is contained in:
2026-08-25 12:30:07 +08:00
parent 12dad6673d
commit 1382902f86
16 changed files with 687 additions and 19 deletions
+2
View File
@@ -11,6 +11,7 @@
|---|---|
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
@@ -23,5 +24,6 @@
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
- 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
public final class CorrelationContext {
public static final String REQUEST_ID = "requestId";
public static final String TRACE_ID = "traceId";
public static final String SPAN_ID = "spanId";
public static final String TRACE_PARENT = "traceparent";
public static final String REQUEST_ID_HEADER = "X-Request-Id";
public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
private CorrelationContext() {}
@@ -22,6 +25,14 @@ public final class CorrelationContext {
return value(request, TRACE_ID);
}
public static String spanId(HttpServletRequest request) {
return value(request, SPAN_ID);
}
public static String traceParent(HttpServletRequest request) {
return value(request, TRACE_PARENT);
}
private static String value(HttpServletRequest request, String key) {
Object attribute = request.getAttribute(key);
return attribute == null ? MDC.get(key) : attribute.toString();
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Enumeration;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false);
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true);
String requestId = requestId(request);
TraceParent traceParent = traceParent(request);
String traceId = traceParent.traceId();
request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
request.setAttribute(CorrelationContext.TRACE_ID, traceId);
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) {
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
filterChain.doFilter(request, response);
}
}
private String acceptedOrGenerated(String candidate, boolean compact) {
if (candidate != null && SAFE_ID.matcher(candidate).matches()) {
return candidate;
private String requestId(HttpServletRequest request) {
HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
return header.value();
}
return UUID.randomUUID().toString();
}
private TraceParent traceParent(HttpServletRequest request) {
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
TraceParent.HEADER_LENGTH);
if (standard.present()) {
if (standard.valid()) {
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
.orElseGet(() -> TraceParent.newRoot(null));
}
return TraceParent.newRoot(null);
}
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
}
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
Enumeration<String> values = request.getHeaders(name);
if (values == null || !values.hasMoreElements()) {
return HeaderValue.missing();
}
String value = values.nextElement();
boolean duplicate = values.hasMoreElements();
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
}
private record HeaderValue(boolean present, boolean valid, String value) {
private static HeaderValue missing() {
return new HeaderValue(false, false, null);
}
String id = UUID.randomUUID().toString();
return compact ? id.replace("-", "") : id;
}
}
@@ -0,0 +1,84 @@
package com.baiyue.common.web;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Strict W3C {@code traceparent} version 00 value.
*
* <p>This type deliberately accepts only the currently frozen version-00 shape. Unsupported,
* ambiguous or malformed input must start a new trace instead of being partially trusted.</p>
*/
public record TraceParent(String traceId, String parentId, String traceFlags) {
public static final String HEADER = "traceparent";
public static final int HEADER_LENGTH = 55;
private static final Pattern VERSION_00 = Pattern.compile(
"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})");
private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}");
private static final String ZERO_TRACE_ID = "0".repeat(32);
private static final String ZERO_PARENT_ID = "0".repeat(16);
private static final SecureRandom RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
public TraceParent {
if (!validTraceId(traceId) || !validParentId(parentId)
|| traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) {
throw new IllegalArgumentException("Invalid W3C traceparent components");
}
}
/** Parses one exact, lowercase W3C version-00 header value. */
public static Optional<TraceParent> parse(String value) {
if (value == null || value.length() != HEADER_LENGTH) {
return Optional.empty();
}
Matcher matcher = VERSION_00.matcher(value);
if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) {
return Optional.empty();
}
return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3)));
}
/** Creates the local inbound context for a valid remote parent. */
public static TraceParent childOf(TraceParent remoteParent) {
return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags());
}
/** Creates a new root context, optionally reusing a valid legacy trace ID. */
public static TraceParent newRoot(String legacyTraceId) {
String traceId = validLegacyTraceId(legacyTraceId)
? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16);
return new TraceParent(traceId, randomNonZero(8), "00");
}
public static boolean validLegacyTraceId(String value) {
return value != null && LEGACY_TRACE_ID.matcher(value).matches()
&& !ZERO_TRACE_ID.equalsIgnoreCase(value);
}
public String value() {
return "00-" + traceId + '-' + parentId + '-' + traceFlags;
}
private static boolean validTraceId(String value) {
return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value);
}
private static boolean validParentId(String value) {
return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value);
}
private static String randomNonZero(int byteCount) {
byte[] bytes = new byte[byteCount];
String value;
do {
RANDOM.nextBytes(bytes);
value = HEX.formatHex(bytes);
} while (value.chars().allMatch(character -> character == '0'));
return value;
}
}
@@ -1,6 +1,8 @@
package com.baiyue.common.web;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
assertThat(context).hasSingleBean(CorrelationIdFilter.class);
assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value())
.isEqualTo(Ordered.HIGHEST_PRECEDENCE);
});
}
@@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("请求链路标识过滤功能")
@@ -13,27 +15,85 @@ class CorrelationIdFilterTest {
private final CorrelationIdFilter filter = new CorrelationIdFilter();
@Test
void preservesSafeIncomingIds() throws Exception {
void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123");
assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123");
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
}
@Test
void replacesUnsafeIncomingIds() throws Exception {
void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.isEqualTo("abcdef0123456789abcdef0123456789");
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
.matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
}
@Test
void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-00000000000000000000000000000000-00f067aa0ba902b7-01");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.matches("[0-9a-f]{32}")
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.isNotEqualTo("00000000000000000000000000000000");
}
@Test
void rejectsDuplicateAndOversizedHeaders() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129));
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER))
.matches("[0-9a-f-]{36}");
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.matches("[0-9a-f]{32}");
.matches("[0-9a-f]{32}")
.isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736")
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
}
private static final class CapturingFilterChain extends MockFilterChain {
private String traceId;
private String spanId;
@Override
public void doFilter(jakarta.servlet.ServletRequest request,
jakarta.servlet.ServletResponse response) throws IOException {
traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID);
spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID);
}
}
}
@@ -0,0 +1,46 @@
package com.baiyue.common.web;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TraceParentTest {
@Test
void parsesCanonicalVersion00AndCreatesLocalChild() {
TraceParent remote = TraceParent.parse(
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow();
TraceParent local = TraceParent.childOf(remote);
assertThat(local.traceId()).isEqualTo(remote.traceId());
assertThat(local.parentId()).matches("[0-9a-f]{16}")
.isNotEqualTo(remote.parentId())
.isNotEqualTo("0000000000000000");
assertThat(local.traceFlags()).isEqualTo("01");
assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH);
}
@Test
void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() {
assertThat(TraceParent.parse(
"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty();
assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty();
}
@Test
void createsCanonicalNonZeroRootAndNormalizesLegacyHex() {
TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789");
assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789");
assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
assertThat(root.value()).matches(
"00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
}
}
+14
View File
@@ -10,6 +10,20 @@ lastleg:
smart-dispatch-api:
enabled: true
baiyue:
http:
clients:
unload-photo-file:
connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s}
relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL}
@@ -0,0 +1,295 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const sourceRoots = {
main: path.join(moduleRoot, 'src', 'main', 'java'),
test: path.join(moduleRoot, 'src', 'test', 'java'),
};
const internalPrefix = 'com.baiyue.lastleg.';
const sliceRules = [
['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/],
['inventory', /(?:^|\.)inventory(?:\.|$)/],
['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/],
['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/],
['dropship', /(?:^|\.)dropship(?:\.|$)/],
['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/],
['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/],
['tailleg-handoff', /(?:^|\.)(?:controller|dto|entity|mapper|model|repository|service|shipping)(?:\.|$)/],
];
function parseArgs(argv) {
const options = { sourceSet: 'main', format: 'json', output: null, check: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--source-set') options.sourceSet = argv[++index];
else if (argument === '--format') options.format = argv[++index];
else if (argument === '--output') options.output = argv[++index];
else if (argument === '--check') options.check = argv[++index];
else if (argument === '--help') {
process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n');
process.exit(0);
} else throw new Error(`Unknown argument: ${argument}`);
}
if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`);
if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`);
return options;
}
function walkJavaFiles(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(absolute);
else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute);
}
}
return result.sort();
}
function stripCommentsAndStrings(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/\/\/[^\r\n]*/g, ' ')
.replace(/"(?:\\.|[^"\\])*"/g, '""')
.replace(/'(?:\\.|[^'\\])*'/g, "''");
}
function classifySlice(fqcn) {
for (const [slice, expression] of sliceRules) {
if (expression.test(fqcn)) return slice;
}
return 'shared-technical';
}
function classifyKind(fqcn) {
const packageName = fqcn.slice(0, fqcn.lastIndexOf('.'));
if (/(?:^|\.)(?:client|gateway)(?:\.|$)/.test(packageName)) return 'cross-service-adapter';
if (/(?:^|\.)(?:adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
if (/(?:^|\.)(?:entity|dto|model)(?:\.|$)/.test(packageName)) return 'data-model';
return 'business-application';
}
function parseSource(file, sourceSet) {
const raw = fs.readFileSync(file, 'utf8');
const source = stripCommentsAndStrings(raw);
const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1];
const typeName = path.basename(file, '.java');
if (!packageName) throw new Error(`Missing package declaration: ${file}`);
const fqcn = `${packageName}.${typeName}`;
const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]);
return {
file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'),
sourceSet,
packageName,
typeName,
fqcn,
imports,
source,
slice: classifySlice(fqcn),
kind: classifyKind(fqcn),
};
}
function collectSources(sourceSet) {
const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet];
return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set)));
}
function resolveDependencies(sources) {
const knownTypes = new Map(sources.map(source => [source.fqcn, source]));
const typesByPackage = new Map();
for (const source of sources) {
if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []);
typesByPackage.get(source.packageName).push(source);
}
const dependencies = [];
const seen = new Set();
for (const source of sources) {
const candidates = new Set();
for (const imported of source.imports) {
if (knownTypes.has(imported)) candidates.add(imported);
else if (imported.startsWith(internalPrefix)) {
const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`));
if (owner) candidates.add(owner);
}
}
for (const samePackageType of typesByPackage.get(source.packageName) ?? []) {
if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) {
candidates.add(samePackageType.fqcn);
}
}
for (const targetFqcn of candidates) {
if (targetFqcn === source.fqcn) continue;
const key = `${source.fqcn}->${targetFqcn}`;
if (seen.has(key)) continue;
seen.add(key);
dependencies.push({ source: source.fqcn, target: targetFqcn });
}
}
return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
}
function aggregateEdges(dependencies, nodeOf) {
const counts = new Map();
for (const dependency of dependencies) {
const source = nodeOf(dependency.source);
const target = nodeOf(dependency.target);
if (source === target) continue;
const key = `${source}\0${target}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts].map(([key, count]) => {
const [source, target] = key.split('\0');
return { source, target, count };
}).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
}
function stronglyConnectedComponents(nodes, edges) {
const adjacency = new Map(nodes.map(node => [node, []]));
for (const edge of edges) adjacency.get(edge.source)?.push(edge.target);
const indexByNode = new Map();
const lowLink = new Map();
const stack = [];
const onStack = new Set();
const components = [];
let nextIndex = 0;
function visit(node) {
indexByNode.set(node, nextIndex);
lowLink.set(node, nextIndex);
nextIndex += 1;
stack.push(node);
onStack.add(node);
for (const target of adjacency.get(node) ?? []) {
if (!indexByNode.has(target)) {
visit(target);
lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target)));
} else if (onStack.has(target)) {
lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target)));
}
}
if (lowLink.get(node) === indexByNode.get(node)) {
const component = [];
let current;
do {
current = stack.pop();
onStack.delete(current);
component.push(current);
} while (current !== node);
components.push(component.sort());
}
}
for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node);
return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
}
function buildReport(sourceSet) {
const sources = collectSources(sourceSet);
const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source]));
const dependencies = resolveDependencies(sources);
const packages = [...new Set(sources.map(source => source.packageName))].sort();
const slices = [...new Set(sources.map(source => source.slice))].sort();
const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName);
const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice);
const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice);
const modelLeaks = directCrossSliceDependencies.filter(edge => sourceByFqcn.get(edge.target).kind === 'data-model');
const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName));
return {
schemaVersion: 1,
generatedFrom: sourceSet,
moduleRoot: 'lastleg-service',
summary: {
javaFiles: sources.length,
internalClassDependencies: dependencies.length,
packages: packages.length,
slices: slices.length,
crossSliceClassDependencies: directCrossSliceDependencies.length,
crossSliceModelLeaks: modelLeaks.length,
crossSliceMapperLeaks: mapperLeaks.length,
},
sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
classes: sources.map(({ source, ...entry }) => entry),
classDependencies: dependencies,
packageEdges,
sliceEdges,
packageSccs: stronglyConnectedComponents(packages, packageEdges),
sliceSccs: stronglyConnectedComponents(slices, sliceEdges),
directCrossSliceDependencies,
modelLeaks,
mapperLeaks,
};
}
function renderMarkdown(report) {
const leaks = [...report.modelLeaks, ...report.mapperLeaks]
.filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index);
return [
'# Lastleg internal dependency report', '',
`- Source set: ${report.generatedFrom}`,
`- Java files: ${report.summary.javaFiles}`,
`- Internal class dependencies: ${report.summary.internalClassDependencies}`,
`- Packages: ${report.summary.packages}`,
`- Cross-slice class dependencies: ${report.summary.crossSliceClassDependencies}`,
`- Cross-slice model leaks: ${report.summary.crossSliceModelLeaks}`,
`- Cross-slice mapper leaks: ${report.summary.crossSliceMapperLeaks}`,
`- Package SCCs (>1 node): ${report.packageSccs.length}`,
`- Slice SCCs (>1 node): ${report.sliceSccs.length}`,
'', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|',
...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`),
'', '## Slice SCCs', '',
...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
'', '## Package SCCs', '',
...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
'', '## Cross-slice model and mapper leaks', '',
...(leaks.length ? leaks.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``) : ['- None']),
'',
].join('\n');
}
function renderDot(report) {
const lines = ['digraph lastleg_slices {', ' rankdir=LR;'];
for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
lines.push('}', '');
return lines.join('\n');
}
function checkContract(report, contractPath) {
const absolute = path.resolve(process.cwd(), contractPath);
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`);
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort();
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort();
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
}
if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
}
}
if (violations.length > 0) {
process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
}
const options = parseArgs(process.argv.slice(2));
const report = buildReport(options.sourceSet);
if (options.check) checkContract(report, options.check);
const rendered = options.format === 'json'
? `${JSON.stringify(report, null, 2)}\n`
: options.format === 'markdown'
? renderMarkdown(report)
: renderDot(report);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.config;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
@Component
public class LastlegRestClientFactory {
public RestClient build(RestClient.Builder builder, String baseUrl,
LastlegRestClientProperties.ClientTimeouts timeouts) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(timeouts.getReadTimeout());
return builder.requestFactory(requestFactory).baseUrl(baseUrl).build();
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@ConfigurationProperties(prefix = "baiyue.http.clients")
public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; }
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; }
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getWarehouseResource() { return warehouseResource; }
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); }
private static ClientTimeouts required(ClientTimeouts value, String client) {
if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required");
return value;
}
public static class ClientTimeouts {
private Duration connectTimeout;
private Duration readTimeout;
public ClientTimeouts() { }
public ClientTimeouts(Duration connectTimeout, Duration readTimeout) {
setConnectTimeout(connectTimeout);
setReadTimeout(readTimeout);
}
public Duration getConnectTimeout() { return connectTimeout; }
public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); }
public Duration getReadTimeout() { return readTimeout; }
public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); }
private static Duration positive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " must be positive");
}
return value;
}
}
}
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
@@ -19,12 +21,17 @@ import java.io.IOException;
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getUnloadPhotoFile());
}
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.fasterxml.jackson.annotation.JsonAlias;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
@@ -15,6 +17,8 @@ import java.util.Map;
@Component
public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}")
private String baseUrl;
@@ -26,7 +30,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@PostConstruct
void initializeClient() {
this.client = restClientBuilder.baseUrl(baseUrl).build();
this.client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getWarehouseResource());
}
@Override
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.palletops.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
@@ -13,11 +15,16 @@ import java.util.Map;
@Component
public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); }
@PostConstruct void initialize() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getPalletPhysical());
}
@Override
public PalletReference resolve(String palletCode) {
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.relabel.pda.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
@@ -19,12 +21,17 @@ import java.io.IOException;
public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl,
restClientProperties.getRelabelPhotoFile());
}
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
@@ -41,6 +41,20 @@ lastleg:
enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
http:
clients:
unload-photo-file:
connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s}
relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:}