生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27

Merged
admin merged 138 commits from test into main 2026-10-16 18:02:59 +08:00
11 changed files with 356 additions and 11 deletions
Showing only changes of commit 54f79b3902 - Show all commits
@@ -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 org.slf4j.LoggerFactory;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; 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.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** Local serialization supplements the exclusive grant and durable account protection. */ /** Local serialization supplements the exclusive grant and durable account protection. */
@Component @Component
public final class LingxingEgressGate { public final class LingxingEgressGate {
private static final int PERMIT_MAX_SECONDS = 30;
private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class); private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class);
private final LingxingEgressProperties properties; private final LingxingEgressProperties properties;
private final LingxingBudgetAuthority authority; private final LingxingBudgetAuthority authority;
@@ -30,6 +36,23 @@ public final class LingxingEgressGate {
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) { LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
this.properties = properties; this.authority = authority; this.clock = clock; this.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) { public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
try { return guarded(context, operation, hash, call); } try { return guarded(context, operation, hash, call); }
catch (LingxingWmsException error) { catch (LingxingWmsException error) {
@@ -61,7 +84,7 @@ public final class LingxingEgressGate {
if (permit == null || !context.equals(permit.context()) || operation != permit.operation() if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) || !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()))) { || (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); 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. 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. 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 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. 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. 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; 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. 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. VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil; External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil;
the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff;
recoverable only within the original valid exclusive window, after both the persistently returned 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 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. 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 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 requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings. 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 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 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 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; 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: security:
- InternalServiceToken: [] - InternalServiceToken: []
paths: 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: /internal/integrations/lingxing/wms/warehouses/query:
post: post:
summary: Query one page of normal warehouses summary: Query one page of normal warehouses
@@ -154,6 +175,26 @@ components:
in: header in: header
name: X-Internal-Service-Token name: X-Internal-Service-Token
schemas: 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: EgressContext:
type: object type: object
additionalProperties: false 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);
}
}
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*;
public class SelfopEgressController { public class SelfopEgressController {
private final InternalServiceTokenGuard guard;private final SelfopEgressService service; private final InternalServiceTokenGuard guard;private final SelfopEgressService service;
public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;} public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;}
@GetMapping("/runtime") public ApiResponse<SelfopEgressDtos.RuntimeSnapshot> 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("/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<Boolean> 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);} @PostMapping("/complete") public ApiResponse<Boolean> 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);}
} }
@@ -2,6 +2,11 @@ package com.baiyue.lastleg.selfoperated.analytics.request;
import java.time.Instant; import java.time.Instant;
public final class SelfopEgressDtos { public final class SelfopEgressDtos {
private 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 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){} 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) @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
@@ -9,10 +9,26 @@ import java.time.*;
import java.util.*; import java.util.*;
@Service @Service
public class SelfopEgressService { 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; private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock;
@Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());} @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;} 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);} 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) @Transactional(noRollbackFor=EgressRejectedException.class)
public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){ 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"); 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(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)); 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 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".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; 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(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("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"); 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); return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true);
} }
@Transactional(noRollbackFor=EgressRejectedException.class) @Transactional(noRollbackFor=EgressRejectedException.class)
@@ -3,6 +3,30 @@ info:
title: Self-operated Analytics Internal Egress Authority title: Self-operated Analytics Internal Egress Authority
version: 1.0.0 version: 1.0.0
paths: 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: /internal/lastleg/self-operated-analytics/egress/authorize:
post: post:
operationId: authorizeSelfoperatedEgressAttempt operationId: authorizeSelfoperatedEgressAttempt
@@ -11,8 +35,10 @@ paths:
仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配; 仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配;
requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定, requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定,
后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1, 后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1,
expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝, expiresAt 最多 30 秒;VALIDATION 不超过验证根截止及 grant 截止。
BUSINESS 仅接受 production 或 test;TEST 任务入口仅允许受控人工任务及合法派生,不允许自动采集计划。独占交接证明及接续预算由服务端配置核验。 BUSINESS 不受人为会话或 grant 截止阻断,但保留短许可过期、身份、显式停止及账户保护。
OFF 默认拒绝;BUSINESS 仅接受 production 或 test,人工根及合法派生保持原身份,
连续计划根另须连续开关与持久化 planner 身份。独占交接证明及接续计数由服务端配置核验。
security: [{ internalServiceToken: [] }] security: [{ internalServiceToken: [] }]
requestBody: requestBody:
required: true required: true
@@ -64,6 +90,36 @@ components:
securitySchemes: securitySchemes:
internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
schemas: 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: EgressContext:
type: object type: object
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
@@ -4,6 +4,63 @@ import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class SelfopEgressControllerTest { 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<String>();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(){ @Test void invalidInternalTokenCannotReachAuthority(){
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); 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)); 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));