From 54f79b39029094bc7d2c007febbbcc3e08232132 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 22:23:48 +0800 Subject: [PATCH] feat(self-operated): expose private runtime configuration snapshots --- .../LingxingEgressRuntimeController.java | 32 ++++++++++ .../LingxingEgressRuntimeSnapshot.java | 9 +++ .../provider/lingxing/LingxingEgressGate.java | 25 +++++++- .../resources/openapi/lingxing-wms-v1.yaml | 53 ++++++++++++++-- .../LingxingEgressRuntimeControllerTest.java | 48 +++++++++++++++ .../LingxingEgressRuntimeSnapshotTest.java | 57 ++++++++++++++++++ .../request/SelfopEgressController.java | 1 + .../analytics/request/SelfopEgressDtos.java | 5 ++ .../request/SelfopEgressService.java | 20 ++++++- .../self-operated-analytics-egress-v1.yaml | 60 ++++++++++++++++++- .../request/SelfopEgressControllerTest.java | 57 ++++++++++++++++++ 11 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java new file mode 100644 index 000000000..13a329a75 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingEgressRuntimeController.java @@ -0,0 +1,32 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import com.baiyue.integration.provider.lingxing.LingxingEgressGate; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** Internal token is checked before the active gate is inspected. Never calls the provider. */ +@RestController +public class LingxingEgressRuntimeController { + private final InternalServiceTokenGuard guard; + private final LingxingEgressGate gate; + private final LingxingWmsProperties provider; + + public LingxingEgressRuntimeController(InternalServiceTokenGuard guard, LingxingEgressGate gate, + LingxingWmsProperties provider) { + this.guard = guard; this.gate = gate; this.provider = provider; + } + + @Operation(summary = "读取实际领星出口绑定配置的脱敏快照,无供应商或许可副作用") + @GetMapping("/internal/integrations/lingxing/wms/egress/runtime") + public ApiResponse runtime( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token) { + guard.verify(token); + return ApiResponse.success(gate.runtimeSnapshot(provider.enabled())); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java new file mode 100644 index 000000000..521478ed9 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressRuntimeSnapshot.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.dto.lingxing; + +import java.time.Instant; + +/** Bound runtime configuration only. No credentials, addresses or execution attestation. */ +public record LingxingEgressRuntimeSnapshot(int schemaVersion, String service, Instant observedAt, + String mode, String environment, boolean continuousEnabled, String sourceAccountFingerprint, + boolean grantPresent, String grantFingerprint, long intervalMs, int permitMaxSeconds, + boolean providerEnabled, String budgetAuthorityFingerprint) { } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java index df05dea51..05a4e7393 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java @@ -8,12 +8,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Clock; import java.time.Instant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; /** Local serialization supplements the exclusive grant and durable account protection. */ @Component public final class LingxingEgressGate { + private static final int PERMIT_MAX_SECONDS = 30; private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class); private final LingxingEgressProperties properties; private final LingxingBudgetAuthority authority; @@ -30,6 +36,23 @@ public final class LingxingEgressGate { LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) { this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter; } + /** The very properties and clock used by execute; no semaphore, authority, budget or HTTP work. */ + public LingxingEgressRuntimeSnapshot runtimeSnapshot(boolean providerEnabled) { + String grant = properties.externalExclusiveGrantId(); + return new LingxingEgressRuntimeSnapshot(1, "integration", clock.instant(), + properties.collectionMode().name(), properties.environmentLabel(), + properties.continuousBusinessCollectionEnabled(), fingerprint("source-account", properties.sourceAccount()), + grant != null && !grant.isBlank(), fingerprint("grant", grant), properties.minStartInterval().toMillis(), + PERMIT_MAX_SECONDS, providerEnabled, fingerprint("budget-authority", properties.budgetAuthorityBaseUri() == null ? null + : properties.budgetAuthorityBaseUri().toString())); + } + private static String fingerprint(String domain, String value) { + if (value == null || value.isBlank()) return null; + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA256_UNAVAILABLE", impossible); } + } public T execute(EgressContext context, Operation operation, String hash, AuthorizedCall call) { try { return guarded(context, operation, hash, call); } catch (LingxingWmsException error) { @@ -61,7 +84,7 @@ public final class LingxingEgressGate { if (permit == null || !context.equals(permit.context()) || operation != permit.operation() || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) - || permit.expiresAt().isAfter(clock.instant().plusSeconds(30)) + || permit.expiresAt().isAfter(clock.instant().plusSeconds(PERMIT_MAX_SECONDS)) || (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) { recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); } diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index 3c1a15a9b..e52a65bcb 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -6,16 +6,17 @@ info: Single-page or single-order adapter. No scheduling, business persistence or public Gateway route. Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone. Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive - account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled manual commands and their legitimate derived work; the adapter does not schedule work. All five endpoints apply the same gate. + account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled commands and their legitimate derived work; the adapter does not schedule work. Provider-query endpoints apply the same gate. Missing exclusive proof, persistent account budget authorization or control context fails closed before egress. - At most one account request is in flight and HTTP request starts are separated by at least 5 seconds. + At most one account request is in flight; request starts obey the bound minStartInterval (VALIDATION at least 5 seconds). A JVM lock is only local protection and is not proof of cross-environment account exclusivity. Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries; 429 is never retried, immediately pauses the account and returns persistent cooldown metadata. VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence. - External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil; - the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is - recoverable only within the original valid exclusive window, after both the persistently returned + External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil; + the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff; + explicit stops, persistent account protections and each short single-attempt permit remain enforced. + A BUSINESS rate-limit pause is recoverable after both the persistently returned cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state. Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost @@ -32,7 +33,7 @@ x-lastleg-egress-coordination: requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization is refused; arbitrary client-created attempts are refused. complete verifies the same bindings. A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true - and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil, + and budgetConsumed=true, expires after now but no later than now plus 30 seconds (also within externalExclusiveUntil for VALIDATION), and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress; @@ -50,6 +51,26 @@ x-lastleg-egress-coordination: security: - InternalServiceToken: [] paths: + /internal/integrations/lingxing/wms/egress/runtime: + get: + operationId: lingxingEgressRuntime + summary: Read sanitized configuration from the active egress gate + description: >- + Internal token authentication only. No provider, database, budget-authority, permit or persistence side effect. + Returns actual bound configuration, not a verified claim or a commit identity. Fingerprints use lowercase + SHA-256 of UTF-8 "selfop-runtime:v1:\n" plus the exact original nonblank value, without trimming. + Domains are source-account, grant and budget-authority. Missing/blank values yield null, never an empty-value hash. + security: [{InternalServiceToken: []}] + responses: + '200': + description: ApiResponse code 200 with runtime snapshot, or A0401 for missing/invalid internal token + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/EgressRuntimeSnapshot'} /internal/integrations/lingxing/wms/warehouses/query: post: summary: Query one page of normal warehouses @@ -154,6 +175,26 @@ components: in: header name: X-Internal-Service-Token schemas: + EgressRuntimeSnapshot: + type: object + additionalProperties: false + required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, grantPresent, intervalMs, permitMaxSeconds, providerEnabled] + properties: + schemaVersion: {type: integer, enum: [1]} + service: {type: string, enum: [integration]} + observedAt: {type: string, format: date-time} + mode: {type: string, enum: [OFF, VALIDATION, BUSINESS]} + environment: {type: string} + continuousEnabled: {type: boolean} + sourceAccountFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + grantPresent: {type: boolean} + grantFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} + intervalMs: + oneOf: [{type: integer, format: int64}, {type: string, pattern: '^[0-9]+$'}] + description: Actual bound interval; shared long-value JSON serialization may encode it as a decimal string. + permitMaxSeconds: {type: integer, enum: [30]} + providerEnabled: {type: boolean} + budgetAuthorityFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'} EgressContext: type: object additionalProperties: false diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java new file mode 100644 index 000000000..d6c608107 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingEgressRuntimeControllerTest.java @@ -0,0 +1,48 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot; +import com.baiyue.integration.provider.lingxing.LingxingEgressGate; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class LingxingEgressRuntimeControllerTest { + @Test void internalTokenIsRequiredBeforeGateInspectionAndPublicPathDoesNotExist() throws Exception { + String token = UUID.randomUUID().toString();var guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token);ReflectionTestUtils.invokeMethod(guard, "initializeToken"); + var gate = mock(LingxingEgressGate.class);var provider = new LingxingWmsProperties(false,null,null,null,null,null); + var converter = new MappingJackson2HttpMessageConverter(Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build()); + var mvc = MockMvcBuilders.standaloneSetup(new LingxingEgressRuntimeController(guard,gate,provider)) + .setMessageConverters(converter).setControllerAdvice(new GlobalExceptionHandler()).build(); + String path = "/internal/integrations/lingxing/wms/egress/runtime"; + for (String bad : new String[] {"", "wrong", "Bearer user-jwt"}) { + mvc.perform(get(path).header("X-Internal-Service-Token",bad)).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + } + verifyNoInteractions(gate); + mvc.perform(get("/api/integrations/lingxing/wms/egress/runtime")).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(org.hamcrest.Matchers.not("200"))) + .andExpect(jsonPath("$.data.service").doesNotExist()); + verifyNoInteractions(gate); + var snapshot = new LingxingEgressRuntimeSnapshot(1,"integration",Instant.parse("2026-10-13T14:00:00Z"), + "OFF","test",false,null,false,null,5000,30,false,null); + when(gate.runtimeSnapshot(false)).thenReturn(snapshot); + mvc.perform(get(path).header("X-Internal-Service-Token",token)).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")).andExpect(jsonPath("$.data.service").value("integration")) + .andExpect(jsonPath("$.data.observedAt").value("2026-10-13T14:00:00Z")) + .andExpect(jsonPath("$.data.mode").value("OFF")).andExpect(jsonPath("$.data.providerEnabled").value(false)) + .andExpect(jsonPath("$.data.verified").doesNotExist()).andExpect(jsonPath("$.data.grant").doesNotExist()); + verify(gate).runtimeSnapshot(false);verifyNoMoreInteractions(gate); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java new file mode 100644 index 000000000..a87b6342f --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressRuntimeSnapshotTest.java @@ -0,0 +1,57 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LingxingEgressRuntimeSnapshotTest { + private static final Instant NOW = Instant.parse("2026-10-13T14:00:00Z"); + private final LingxingBudgetAuthority authority = mock(LingxingBudgetAuthority.class); + private LingxingEgressGate gate(CollectionMode mode, String account, String grant, URI uri) { + return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", account, grant, null, uri, + mode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1200), null, true), + authority, Clock.fixed(NOW, ZoneOffset.UTC), millis -> { throw new AssertionError("DIAGNOSTIC_MUST_NOT_WAIT"); }); + } + private static String hash(String domain, String value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8))); + } + @Test void sameGateReturnsActualBindingsWithDomainSeparatedExactRawFingerprints() throws Exception { + var snapshot = gate(CollectionMode.BUSINESS, "same-raw-value", "same-raw-value", URI.create("http://authority:9303")).runtimeSnapshot(true); + assertThat(snapshot.schemaVersion()).isEqualTo(1);assertThat(snapshot.service()).isEqualTo("integration"); + assertThat(snapshot.observedAt()).isEqualTo(NOW);assertThat(snapshot.mode()).isEqualTo("BUSINESS"); + assertThat(snapshot.environment()).isEqualTo("test");assertThat(snapshot.continuousEnabled()).isTrue(); + assertThat(snapshot.providerEnabled()).isTrue();assertThat(snapshot.grantPresent()).isTrue(); + assertThat(snapshot.intervalMs()).isEqualTo(1200);assertThat(snapshot.permitMaxSeconds()).isEqualTo(30); + assertThat(snapshot.sourceAccountFingerprint()).isEqualTo(hash("source-account", "same-raw-value")); + assertThat(snapshot.grantFingerprint()).isEqualTo(hash("grant", "same-raw-value")).isNotEqualTo(snapshot.sourceAccountFingerprint()); + assertThat(snapshot.budgetAuthorityFingerprint()).isEqualTo(hash("budget-authority", "http://authority:9303")); + assertThat(gate(CollectionMode.BUSINESS, " raw ", " raw ", null).runtimeSnapshot(false).grantFingerprint()) + .isEqualTo(hash("grant", " raw ")).isNotEqualTo(hash("grant", "raw")); + verifyNoInteractions(authority); + } + @Test void offAndValidationAreObservableAndAbsentOrBlankValuesAreNotHashed() { + for (String value : new String[] {null, "", " "}) { + var s = gate(CollectionMode.OFF, value, value, null).runtimeSnapshot(false); + assertThat(s.mode()).isEqualTo("OFF");assertThat(s.sourceAccountFingerprint()).isNull(); + assertThat(s.grantPresent()).isFalse();assertThat(s.grantFingerprint()).isNull(); + assertThat(s.budgetAuthorityFingerprint()).isNull();assertThat(s.providerEnabled()).isFalse(); + } + assertThat(gate(CollectionMode.VALIDATION, null, null, null).runtimeSnapshot(false).intervalMs()).isEqualTo(5000); + verifyNoInteractions(authority); + } + @Test void serializedSnapshotContainsNoRawIdentityAddressCredentialOrVerifiedClaim() throws Exception { + var raw = new ObjectMapper().findAndRegisterModules().writeValueAsString( + gate(CollectionMode.BUSINESS, "private-account", "private-grant", URI.create("http://private-host:9303")).runtimeSnapshot(true)); + assertThat(raw).doesNotContain("private-account", "private-grant", "private-host", "http://", "verified", "appSecret", "internalServiceToken"); + verifyNoInteractions(authority); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java index 591fd1485..0a193dcb7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*; public class SelfopEgressController { private final InternalServiceTokenGuard guard;private final SelfopEgressService service; public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;} + @GetMapping("/runtime") public ApiResponse runtime(@RequestHeader(value="X-Internal-Service-Token",required=false) String token){guard.verify(token);return ApiResponse.success(service.runtime());} @PostMapping("/authorize") public ApiResponse authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}} @PostMapping("/complete") public ApiResponse complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java index 438f90d82..a48931710 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java @@ -2,6 +2,11 @@ package com.baiyue.lastleg.selfoperated.analytics.request; import java.time.Instant; public final class SelfopEgressDtos { private SelfopEgressDtos(){} + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) + public record RuntimeSnapshot(int schemaVersion,String service,Instant observedAt,String mode,String environment, + boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint, + long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds, + boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){} public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY} public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index ad6016d53..955a10473 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -9,10 +9,26 @@ import java.time.*; import java.util.*; @Service public class SelfopEgressService { + private static final int PERMIT_MAX_SECONDS=30,PENDING_MAX_SECONDS=120; private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock; @Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());} SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;} private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);} + /** Reads the same configuration instance used by authorization; no account, permit or provider access. */ + public RuntimeSnapshot runtime(){ + return new RuntimeSnapshot(1,"lastleg",clock.instant(),config.getCollectionMode(),config.getEnvironment(), + config.isContinuousBusinessCollectionEnabled(),fingerprint("source-account",config.getSourceAccount()), + config.getExclusiveGrantId()!=null&&!config.getExclusiveGrantId().isBlank(),fingerprint("grant",config.getExclusiveGrantId()), + config.getBusinessMinRequestIntervalMs(),PERMIT_MAX_SECONDS,config.isExecutionEnabled(),pendingMaxSeconds(), + config.isExclusiveHandoffVerified(),config.isPriorEgressClosed(),config.isInflightDrained(),config.isTransferredBudgetVerified()); + } + private int pendingMaxSeconds(){return Math.max(1,Math.min(PENDING_MAX_SECONDS,config.getEgressLeaseSeconds()));} + private static String fingerprint(String domain,String value){ + if(value==null||value.isBlank())return null; + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(("selfop-runtime:v1:"+domain+"\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));} + catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException("SHA-256 unavailable",impossible);} + } @Transactional(noRollbackFor=EgressRejectedException.class) public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){ if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID"); @@ -41,7 +57,7 @@ public class SelfopEgressService { if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000)); if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); - var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); + var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(pendingMaxSeconds()); if("VALIDATION".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; @@ -66,7 +82,7 @@ public class SelfopEgressService { if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED"); if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}} if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID"); - pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget); + pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(PERMIT_MAX_SECONDS);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget); return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true); } @Transactional(noRollbackFor=EgressRejectedException.class) diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml index c3df64461..cfcd5e612 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml @@ -3,6 +3,30 @@ info: title: Self-operated Analytics Internal Egress Authority version: 1.0.0 paths: + /internal/lastleg/self-operated-analytics/egress/runtime: + get: + operationId: getSelfoperatedEgressRuntime + summary: 读取脱敏的实际出口配置快照 + description: >- + 仅内部服务令牌,不提供公网路由。直接读取 Egress 执行所用的同一配置实例, + 不读取数据库、不预留或消费许可、不调用供应商。响应不是部署验证结论, + 不包含 verified、镜像版本、原始账号、grant、Token 或 URL。 + intervalMs 为当前配置间隔,持久账户间隔可能更严格,须另行只读核验。 + pendingMaxSeconds 为实际配置经 1 至 120 秒裁剪后的上限;permitMaxSeconds 为单次许可上限, + VALIDATION 仍可能受验证截止及 grant 截止进一步缩短。 + security: [{ internalServiceToken: [] }] + responses: + '200': + description: ApiResponse 成功响应,data 为只读白名单快照 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/RuntimeSnapshot' } + '401': { description: 内部令牌无效 } + '503': { description: 内部令牌未配置 } /internal/lastleg/self-operated-analytics/egress/authorize: post: operationId: authorizeSelfoperatedEgressAttempt @@ -11,8 +35,10 @@ paths: 仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配; requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定, 后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1, - expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝, - BUSINESS 仅接受 production 或 test;TEST 任务入口仅允许受控人工任务及合法派生,不允许自动采集计划。独占交接证明及接续预算由服务端配置核验。 + expiresAt 最多 30 秒;VALIDATION 不超过验证根截止及 grant 截止。 + BUSINESS 不受人为会话或 grant 截止阻断,但保留短许可过期、身份、显式停止及账户保护。 + OFF 默认拒绝;BUSINESS 仅接受 production 或 test,人工根及合法派生保持原身份, + 连续计划根另须连续开关与持久化 planner 身份。独占交接证明及接续计数由服务端配置核验。 security: [{ internalServiceToken: [] }] requestBody: required: true @@ -64,6 +90,36 @@ components: securitySchemes: internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } schemas: + RuntimeSnapshot: + type: object + additionalProperties: false + required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, sourceAccountFingerprint, grantPresent, grantFingerprint, intervalMs, permitMaxSeconds, executionEnabled, pendingMaxSeconds, exclusiveHandoffVerified, priorEgressClosed, inflightDrained, transferredBudgetVerified] + properties: + schemaVersion: { type: integer, enum: [1] } + service: { type: string, enum: [lastleg] } + observedAt: { type: string, format: date-time, description: UTC 观测时间 } + mode: { type: string, nullable: true, description: 原样返回实际配置,不将未知值转为 BUSINESS } + environment: { type: string, nullable: true } + continuousEnabled: { type: boolean } + sourceAccountFingerprint: + type: string + nullable: true + pattern: '^[a-f0-9]{64}$' + description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:source-account 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。' + grantPresent: { type: boolean, description: grant 原值非 null 且非 blank } + grantFingerprint: + type: string + nullable: true + pattern: '^[a-f0-9]{64}$' + description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:grant 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。' + intervalMs: { type: integer, format: int64, description: 实际配置的 BUSINESS 最小请求间隔毫秒数,未合并持久账户间隔 } + permitMaxSeconds: { type: integer, enum: [30] } + executionEnabled: { type: boolean } + pendingMaxSeconds: { type: integer, minimum: 1, maximum: 120 } + exclusiveHandoffVerified: { type: boolean } + priorEgressClosed: { type: boolean } + inflightDrained: { type: boolean } + transferredBudgetVerified: { type: boolean } EgressContext: type: object required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java index df4467a3f..c2ed86316 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java @@ -4,6 +4,63 @@ import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; class SelfopEgressControllerTest { + @Test void runtimeRequiresInternalTokenBeforeReadingConfiguration(){ + var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); + for(String token:new String[]{null,"","wrong"})assertThatThrownBy(()->c.runtime(token)) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class) + .satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); + verifyNoInteractions(service); + var missing=new SelfopEgressController(new InternalServiceTokenGuard(""),service); + assertThatThrownBy(()->missing.runtime("synthetic")).isInstanceOf(org.springframework.web.server.ResponseStatusException.class) + .satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(503)); + verifyNoInteractions(service);c.runtime("synthetic");verify(service).runtime(); + } + @Test void runtimeUsesExecutionConfigurationAndSerializesOnlyWhitelistedFields() throws Exception { + var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties(); + config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + config.setSourceAccount("synthetic-private-account");config.setExclusiveGrantId("synthetic-private-grant"); + config.setInternalServiceToken("synthetic-private-token");config.setIntegrationBaseUrl("http://synthetic-private-host"); + config.setBusinessMinRequestIntervalMs(1200);config.setEgressLeaseSeconds(999); + config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true); + var now=java.time.Instant.parse("2026-10-13T00:00:00Z"); + var service=new SelfopEgressService(storage,config,java.time.Clock.fixed(now,java.time.ZoneOffset.UTC)); + var controller=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); + var response=controller.runtime("synthetic");var runtime=response.data(); + assertThat(runtime.schemaVersion()).isEqualTo(1);assertThat(runtime.service()).isEqualTo("lastleg");assertThat(runtime.observedAt()).isEqualTo(now); + assertThat(runtime.mode()).isEqualTo("BUSINESS");assertThat(runtime.environment()).isEqualTo("test");assertThat(runtime.continuousEnabled()).isTrue(); + assertThat(runtime.intervalMs()).isEqualTo(1200);assertThat(runtime.permitMaxSeconds()).isEqualTo(30);assertThat(runtime.pendingMaxSeconds()).isEqualTo(120); + assertThat(runtime.executionEnabled()).isTrue();assertThat(runtime.grantPresent()).isTrue(); + assertThat(runtime.exclusiveHandoffVerified()&&runtime.priorEgressClosed()&&runtime.inflightDrained()&&runtime.transferredBudgetVerified()).isTrue(); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + String serialized=mapper.writeValueAsString(response);var data=mapper.readTree(serialized).path("data"); + var names=new java.util.HashSet();data.fieldNames().forEachRemaining(names::add); + assertThat(names).containsExactlyInAnyOrder("schemaVersion","service","observedAt","mode","environment","continuousEnabled", + "sourceAccountFingerprint","grantPresent","grantFingerprint","intervalMs","permitMaxSeconds","executionEnabled","pendingMaxSeconds", + "exclusiveHandoffVerified","priorEgressClosed","inflightDrained","transferredBudgetVerified"); + assertThat(serialized).doesNotContain("synthetic-private-account","synthetic-private-grant","synthetic-private-token","synthetic-private-host"); + config.setCollectionMode("VALIDATION");config.setContinuousBusinessCollectionEnabled(false);config.setBusinessMinRequestIntervalMs(5000);config.setEgressLeaseSeconds(0); + var changed=controller.runtime("synthetic").data();assertThat(changed.mode()).isEqualTo("VALIDATION");assertThat(changed.continuousEnabled()).isFalse(); + assertThat(changed.intervalMs()).isEqualTo(5000);assertThat(changed.pendingMaxSeconds()).isEqualTo(1); + assertThat(runtime.mode()).isEqualTo("BUSINESS");verifyNoInteractions(storage); + } + @Test void runtimeFingerprintsUseRawUtf8WithSeparateDomainsAndExplicitNulls() throws Exception { + var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties(); + var service=new SelfopEgressService(storage,config); + String value=" fixture-账户 ";config.setSourceAccount(value);config.setExclusiveGrantId(value); + var digest=java.security.MessageDigest.getInstance("SHA-256");var hex=java.util.HexFormat.of(); + var runtime=service.runtime(); + assertThat(runtime.sourceAccountFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:source-account\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + assertThat(runtime.grantFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:grant\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + assertThat(runtime.sourceAccountFingerprint()).isNotEqualTo(runtime.grantFingerprint()); + config.setExclusiveGrantId(value.trim());assertThat(service.runtime().grantFingerprint()).isNotEqualTo(runtime.grantFingerprint()); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + for(String empty:new String[]{null,""," \t"}){ + config.setSourceAccount(empty);config.setExclusiveGrantId(empty);var absent=service.runtime(); + assertThat(absent.grantPresent()).isFalse();assertThat(absent.grantFingerprint()).isNull();assertThat(absent.sourceAccountFingerprint()).isNull(); + var node=mapper.valueToTree(absent);assertThat(node.has("grantFingerprint")).isTrue();assertThat(node.path("grantFingerprint").isNull()).isTrue(); + } + verifyNoInteractions(storage); + } @Test void invalidInternalTokenCannotReachAuthority(){ var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));