feat(self-operated): expose private runtime configuration snapshots

This commit is contained in:
2026-10-13 22:23:48 +08:00
parent 1d0b0918ba
commit 54f79b3902
11 changed files with 356 additions and 11 deletions
@@ -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<LingxingEgressRuntimeSnapshot> runtime(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token) {
guard.verify(token);
return ApiResponse.success(gate.runtimeSnapshot(provider.enabled()));
}
}
@@ -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) { }
@@ -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> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> 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");
}
@@ -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:<domain>\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
@@ -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);
}
}
@@ -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);
}
}